---
title: "Destination policy — Safe Fetch for Laravel | Roundly"
description: "DestinationPolicy — the withers that tighten or loosen per call, host patterns, the order of the rules, private hosts and a policy built from scratch."
url: https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel/policies
language: en
---

[All packages](https://roundly-consulting.com/open-source.md)

[Safe Fetch for Laravel](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)

# Destination policy

DestinationPolicy is what the guard lets through on top of the blocked-network table. SafeFetch::policy() is the configured default; every wither returns a new policy, so a call can tighten or loosen it locally:

```php
Http::safe(SafeFetch::policy()->allowHosts(['hooks.example.com'])->followRedirects())->post($url, $payload);
```

## Withers

| Method | Default | Does |
| --- | --- | --- |
| `allowHttp() · httpsOnly()` | `['https']` | http as well, or https only. |
| `ports(?array $ports)` | `null` | The only ports a URL may use; null for any. |
| `allowHosts(array $hosts)` | `[]` | Replaces the strict allowlist (so it can widen a configured one) and refuses an empty list (InvalidArgumentException). |
| `withoutHostAllowlist()` | — | Lifts the allowlist — said explicitly. |
| `blockHosts(array $hosts)` | `[]` | Adds blocked host patterns. |
| `trustPrivateHosts(array $hosts) · withoutPrivateHosts()` | `[]` | Adds names whose addresses may be private · clears them. |
| `allowRanges(array $ranges) · withoutAllowedRanges()` | `[]` | Adds addresses or CIDRs exempt from the table · clears them. |
| `blockRanges(array $ranges)` | `[]` | Adds ranges that are always refused and beat every allow. |
| `blockAppHosts(bool $block = true) · withoutAppHostBlock()` | `true` | The app-host block on or off, over whatever the config says. |
| `appHosts(array $hosts)` | app.url’s host + app\_hosts.extra | Adds names that mean the app. |
| `redirects(RedirectPolicy $redirects) · followRedirects(int $max = 3)` | off | Sets the redirect policy · follows 307/308 up to $max hops. |
| `maxResponseBytes(?int $bytes)` | `null` | Http::safe() only: cut a response body at this many bytes (at least 1). |

Withers that tighten compose freely; anything that loosens is a call that says so. allowHosts() refuses an empty list for that reason: a list that happens to be empty ($tenant->hosts ?? \[\]) would otherwise allow every host. Branch on it, or call withoutHostAllowlist() when lifting the allowlist is what you mean.

## Validation of the values

The constructor validates everything and throws InvalidArgumentException: schemes http and https only, at least one; ports 1–65535, at least one when a list; host entries are canonical names or \*. plus a name — no bare \*, no scheme, port, path or address (an address belongs in the ranges); ranges are an address or address/prefix; 0.0.0.0/0, ::/0 and every IPv6 prefix that carries all of IPv4 (::ffff:0:0/96, ::/96, 64:ff9b::/96, ::ffff:0:0:0/96, 2002::/16, or anything holding one, such as 2000::/3) are refused in the allowed ranges; a response cap of at least 1.

## Host patterns

Exact names or \*.example.com, matched in any case, one trailing dot dropped on both sides, an international name compared as punycode. \*.example.com matches any subdomain — never example.com itself or badexample.com.

## The order of the rules

First failure wins:

- 1\. Canonicalise the URL (see URLs and blocked networks).
- 2\. A scheme the policy does not allow → scheme.
- 3\. A port the policy does not allow → port.
- 4\. Name rules: a blocked host → blocked (blocked\_host, before any lookup); one of the app’s hosts — or an address literal that is one of its addresses — while the block is on → blocked (app\_host); a strict allowlist that does not list the host → not\_allowed (an address literal is never on it); localhost or \*.localhost not listed as a private host → blocked (hostname).
- 5\. Resolve the name once (an address literal is its own address); nothing → unresolved.
- 6\. Every address: in a blocked range → blocked (blocked\_range); in the table and neither in an allowed range nor the name a private host → blocked (private\_address). An answer that is no address at all is refused whatever the policy trusts.

SafeFetch::precheck() runs steps 1–4, and step 6 for an address literal, without step 5.

## Block beats allow

- An allowlisted host is still resolved, judged and pinned — “may be asked for”, never “may be private”.
- A private host (minio.internal) is still resolved and pinned; only its addresses are exempt from the table (a localhost listed there is looked up too).
- A private host never exempts a cloud-metadata address — all link-local (169.254.0.0/16 and fe80::/10: instance metadata, ECS task credentials at 169.254.170.2, EKS Pod Identity at 169.254.170.23, Tencent at 169.254.0.23), fd00:ec2::/32, 100.100.100.200, 192.0.0.192, 168.63.129.16, or the IPv4 one an IPv6 address carries. Only an allowed range names one back in.
- Ranges match an address and the IPv4 address it carries (127.0.0.1/32 covers ::ffff:127.0.0.1), and an IPv6 range inside a prefix that carries IPv4 also covers the plain IPv4 addresses it carries (::ffff:8.8.8.0/120 covers 8.8.8.8). A blocked range that holds such a prefix whole (2002::/16, ::/0) blocks only its IPv6 forms, never every IPv4 address.

## A policy from scratch

SafeFetch::policy() plus withers is the usual start — it carries your configuration. Both policies can also be built directly; every argument is named and validated:

```php
new DestinationPolicy(
    array $schemes = ['https'], ?array $ports = null,
    array $allowedHosts = [], array $blockedHosts = [], array $privateHosts = [],
    array $allowedRanges = [], array $blockedRanges = [],
    bool $blockAppHosts = true, array $appHosts = [],
    RedirectPolicy $redirects = new RedirectPolicy, ?int $maxResponseBytes = null,
    array $appAddresses = [],
)

new RedirectPolicy(
    bool $enabled = false, int $max = 3, ?array $statuses = null,
    bool $allowDowngrade = false, array $forwardHeaders = [],
)
```

```php
use RoundlyConsulting\SafeFetch\DataTransferObjects\DestinationPolicy;
use RoundlyConsulting\SafeFetch\DataTransferObjects\RedirectPolicy;

$policy = new DestinationPolicy(
    ports: [443],
    allowedHosts: ['*.partner.example'],
    blockedRanges: ['203.0.113.0/24'],
    redirects: RedirectPolicy::methodPreserving(2),
    maxResponseBytes: 65_536,
);

$redirects = new RedirectPolicy(enabled: true, max: 5, statuses: [301, 302, 307, 308], forwardHeaders: ['X-Signature']);
```

A policy built this way knows nothing of config/safe-fetch.php — not even the app’s own host names: blockAppHosts is on, but appHosts and appAddresses are empty until you pass them. The blocked-network table applies whatever the policy says.

## Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

[More ways to support, including crypto](https://roundly-consulting.com/support-us.md)

By donating, you agree to our [donation terms](https://roundly-consulting.com/donation-terms.md).

[Support our open source work (opens in a new tab)](https://donate.stripe.com/dRmeVe8FX5PF1Qd9pXcEw00) [Join us on Patreon (opens in a new tab)](https://www.patreon.com/cw/roundly)

## Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.

[Get a quote in 48 hours](https://roundly-consulting.com/contact.md) [Browse all packages](https://roundly-consulting.com/open-source/docs/safe-fetch-for-laravel.md)
