Skip to main content

Chapter 17 - Configuration and cookies at runtime

Chapter 16 closed Part VII (Observing and Communicating) with two facades that reach past their better-known surface: Mail, sending outside the Mailable-based flow, and Lang, resolving locales beyond the standard fallback chain. Chapter 17 opens Part VIII (Application Infrastructure) with the same idea applied to two more facades every Laravel application already depends on: Config, read and written far more often than its full public surface suggests, and Cookie, whose queue of outgoing values turns out to be inspectable and reversible, not just write-only. The chapter starts with Config.

Config::getMany()

Case type: an undocumented method on Illuminate\Config\Repository (and the Config facade), sitting beside the documented single-key config()/Config::get()/Config::set() that configuration.md covers. Alias flag: not a new, competing method - it is the actual mechanism the documented Config::get() already delegates to whenever it receives an array of keys; calling getMany() directly is about explicitness of intent at the call site, not new capability. Audience: application developers, no shift toward package authors. Stability: core Config component, no churn found verifying against v13.22.0.

Minimal snippet

Config::getMany([
'shipping.default' => null,
'shipping.accounts.default' => null,
]);
// ['shipping.default' => 'flat', 'shipping.accounts.default' => 'ups']

Illuminate\Config\Repository::getMany() accepts either form for each entry: a bare, integer-indexed key with no default, or a key => default pair -

public function getMany($keys)
{
$config = [];

foreach ($keys as $key => $default) {
if (is_numeric($key)) {
[$key, $default] = [$default, null];
}

$config[$key] = Arr::get($this->items, $key, $default);
}

return $config;
}
  • and every lookup goes through Arr::get(), so a missing key never throws, it just falls back to whatever default that key was given.

Documented way vs. discovered way

Config::get() (the same class, and what config() calls internally for a single key) already handles an array argument by delegating straight to getMany():

public function get($key, $default = null)
{
if (is_array($key)) {
return $this->getMany($key);
}

return Arr::get($this->items, $key, $default);
}

So Config::get(['shipping.default' => null, 'shipping.accounts.default' => null]) is a genuinely documented way to reach the same result - the honest comparison here is not "no equivalent exists", it is "the same call, made explicitly instead of through an overload nobody reads the source to discover." Next to it, the ordinary approach most code actually reaches for is three separate single-key calls:

config('shipping.default');
config('shipping.accounts.default');
config('shipping.accounts.instances.ups.driver');

One thing this comparison has to rule out explicitly: the global config() helper's own array form is not a shorthand for either of the above. Passing an array to config() does not read anything at all -

function config($key = null, $default = null)
{
if (is_null($key)) {
return app('config');
}

if (is_array($key)) {
return app('config')->set($key);
}

return app('config')->get($key, $default);
}
  • it sets every key in the array to the paired value. This is not a hypothetical trap: this same codebase already relies on exactly that behavior in a Chapter 16 test (config(['services.fulfillment.alert_address' => 'ops@example.test']), to seed a value before asserting on it), and reaching for the same syntax expecting it to read several keys at once would silently overwrite them instead.

Real scenario: a shipping-configuration diagnostics summary

config/shipping.php already holds several related settings read individually across the codebase - the active rate driver, the default carrier account, and each account's own driver. App\Support\ShippingDiagnostics gathers three of them in one call:

class ShippingDiagnostics
{
public function summary(): array
{
$defaultAccount = config('shipping.accounts.default');

return Config::getMany([
'shipping.default' => null,
'shipping.accounts.default' => null,
"shipping.accounts.instances.{$defaultAccount}.driver" => null,
]);
}
}

The third key cannot be a static literal: its dotted path embeds the second key's own resolved value, so $defaultAccount is read first, with a plain single-key config() call, before the one getMany() call that produces the actual summary. Two tests confirm the behavior from both sides. The first proves the call does real multi-key work against the account currently configured as default:

$summary = (new ShippingDiagnostics)->summary();

expect($summary)->toBe([
'shipping.default' => 'flat',
'shipping.accounts.default' => 'ups',
'shipping.accounts.instances.ups.driver' => 'ups',
]);

The second isolates getMany()'s per-key default against dhl, a carrier that does not exist anywhere under shipping.accounts.instances - not a missing leaf, a missing branch entirely - confirming the fallback holds even then:

$values = Config::getMany([
'shipping.default' => null,
'shipping.accounts.default' => null,
'shipping.accounts.instances.ups.driver' => null,
'shipping.accounts.instances.dhl.driver' => null,
]);

expect($values)->toBe([
'shipping.default' => 'flat',
'shipping.accounts.default' => 'ups',
'shipping.accounts.instances.ups.driver' => 'ups',
'shipping.accounts.instances.dhl.driver' => null,
]);

Config::prepend() / Config::push()

Case type: two undocumented methods on Illuminate\Config\Repository (and the Config facade), sitting beside the documented single-key get()/set() that configuration.md covers. Alias flag: not aliases - each does a real array mutation, not something achievable with set() alone. Audience: application developers; the scenario below has a self-registering-module shape that echoes Chapter 3's Manager/MultipleInstanceManager package-author pattern, worth noting, though nothing here actually shifts the target reader away from an application developer. Stability: core Config component, no churn found verifying against v13.22.0.

Minimal snippet

Config::push('shipping.incident_notification_channels', 'dhl-ops@example.test');
Config::prepend('shipping.incident_notification_channels', 'oncall-escalation@example.test');
// ['oncall-escalation@example.test', 'ops@example.test', 'dhl-ops@example.test']

Neither method guards against adding a value that is already present - both simply read the current array, mutate a local copy, and write it back:

public function prepend($key, $value)
{
$array = $this->get($key, []);

array_unshift($array, $value);

$this->set($key, $array);
}

public function push($key, $value)
{
$array = $this->get($key, []);

$array[] = $value;

$this->set($key, $array);
}

Documented way vs. discovered way

Without either method, the same result needs a manual read, mutation, and write-back through the documented get()/set() pair:

$channels = config('shipping.incident_notification_channels');
array_unshift($channels, 'oncall-escalation@example.test');
$channels[] = 'dhl-ops@example.test';
Config::set('shipping.incident_notification_channels', $channels);

prepend()/push() collapse that into two calls that say directly what they do, at the cost of one call each instead of a single combined write.

Real scenario: a self-registering carrier module

config/shipping.php gained a new key for this entry, deliberately a flat, ordered list rather than the keyed accounts.instances map above it - push()ing onto a keyed map would append at a numeric index and break every lookup that reads it by carrier code:

'incident_notification_channels' => [
'ops@example.test',
],

App\Providers\CarrierNotificationServiceProvider extends that list at boot, as a hypothetical carrier module would register its own incident contact alongside the app's own:

class CarrierNotificationServiceProvider extends ServiceProvider
{
public function boot(): void
{
if (! in_array('dhl-ops@example.test', config('shipping.incident_notification_channels'), true)) {
Config::push('shipping.incident_notification_channels', 'dhl-ops@example.test');
}

if (! in_array('oncall-escalation@example.test', config('shipping.incident_notification_channels'), true)) {
Config::prepend('shipping.incident_notification_channels', 'oncall-escalation@example.test');
}
}
}

Both mutations are guarded by an in_array() check read fresh from config() immediately before each call, not cached in a local variable - prepend()/push() themselves have no notion of "already present," per the source above. This matters more than it looks: under a stateless deployment boot() runs once per request and the guard is merely tidy, but under a long-running worker such as Octane the configuration array survives across requests in the same process, so an unguarded version would append the same two channels again on every request that followed the first. A test proves the guard holds even under direct, repeated invocation:

$provider = new CarrierNotificationServiceProvider($this->app);

$provider->boot();
$provider->boot();
$provider->boot();

expect(config('shipping.incident_notification_channels'))->toBe([
'oncall-escalation@example.test',
'ops@example.test',
'dhl-ops@example.test',
]);

This is worth contrasting with Env::writeVariable() (Chapter 3), used elsewhere in this same codebase by App\Support\ShippingProviderConfigurator:

public function rotateApiKey(string $key): void
{
Env::writeVariable('SHIPPING_API_KEY', $key, $this->environmentFilePath, overwrite: true);
}

Env::writeVariable() persists to the actual .env file on disk - the change survives a process restart. Config::prepend()/Config::push() mutate only the in-memory runtime array: the moment the process ends, the addition is gone, unless something re-applies it at every boot, exactly as CarrierNotificationServiceProvider does here.

Cookie::forever()

Case type: an undocumented method on Illuminate\Cookie\CookieJar (and the Cookie facade), sitting beside the documented make() that responses.md's cookie section covers. Alias flag: yes - a one-line wrapper over make() with a fixed duration, the same case-type already covered for Cache::sear() (Chapter 11). Audience: application developers. Stability: core Cookie/CookieJar component, no churn found verifying against v13.22.0.

Minimal snippet

Cookie::forever('remembered_setting', 'value');

CookieJar::forever() is exactly a call to make() with a fixed 400-day duration baked in:

public function forever($name, $value, $path = null, $domain = null, $secure = null, $httpOnly = true, $raw = false, $sameSite = null)
{
return $this->make($name, $value, 576000, $path, $domain, $secure, $httpOnly, $raw, $sameSite);
}

The 576000-minute figure is not an arbitrary Laravel constant - it works out to exactly 400 days, which is also the maximum lifetime modern browsers (Chrome, Firefox) will honor on any cookie regardless of what a server requests. forever() is aligned with that external ceiling, not inventing one of its own.

Documented way vs. discovered way

Without it, the same 400-day duration has to be computed by hand and passed to make():

Cookie::make('remembered_setting', 'value', 60 * 24 * 400);

forever() collapses that arithmetic into a name that says what it means, at the cost of a fixed duration - there is no parameter for "as long as possible but not quite forever."

Real scenario: remembering an explicitly audited order locale

Chapter 16's OrderController::lookup() already treats an explicitly requested ?locale= differently from an absent one: a named locale is checked against that locale alone, missing keys and all, while an absent one falls through the ordinary locale-resolution chain. This entry adds a third path: a caller who named a locale once has that choice remembered in a cookie, so a later request naming none can still get the strict, drift-detecting treatment instead of silently falling back through Lang::determineLocalesUsing().

public function lookup(Request $request, PreferredLocaleContext $context)
{
$order = Order::query()
->where('phone_number', Str::numbers($request->string('phone')->toString()))
->firstOrFail();

$context->set($order->preferred_locale);

$key = "orders.status.{$order->status}";

$requested = $request->string('locale')->toString();
$requested = in_array($requested, self::SHIPPED_LOCALES, true) ? $requested : '';

$remembered = $request->cookie('preferred_locale_audit');
$remembered = in_array($remembered, self::SHIPPED_LOCALES, true) ? $remembered : null;

$statusLabel = match (true) {
$requested !== '' => Lang::get($key, [], $requested, false),
$remembered !== null => Lang::get($key, [], $remembered, false),
default => Lang::get($key),
};

$response = response()->json([
'uuid' => $order->uuid,
'status' => $order->status,
'status_label' => $statusLabel,
]);

if ($requested !== '') {
$response->withCookie(Cookie::forever('preferred_locale_audit', $requested));
}

return $response;
}

Both $requested and $remembered are validated against SHIPPED_LOCALES - the same whitelist store() already applies to preferred_locale - before either reaches Lang::get(). This is not incidental. Illuminate\Translation\FileLoader concatenates its $locale argument directly into a filesystem path ("{$path}/{$locale}/{$group}.php", loaded via require, and "{$path}/{$locale}.json", decoded and merged with no group restriction at all), with no sanitization anywhere in Translator/FileLoader/Filesystem - no basename() or whitelist of any kind guards that path. $key's group half is fixed server-side, but $locale is exactly what a caller supplies, whether as ?locale= or as the cookie this entry remembers it in for 400 days. Checking against SHIPPED_LOCALES before either value reaches Lang::get() is what keeps that path closed.

One detail here is not cosmetic: Cookie::forever() only builds a Symfony\Component\HttpFoundation\Cookie instance, it does not send it anywhere on its own. The natural-looking Cookie::queue(Cookie::forever(...)) would have compiled, run, and done nothing - Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse is the piece that actually flushes a queued cookie onto the response, and it is only present in the web middleware group by default. OrderController is routed through routes/api.php, served by the api group, which does not include it (nor EncryptCookies, the group's other cookie-related middleware) unless the application opts in. Attaching the cookie directly to this response with withCookie() sidesteps the queue entirely, so it works regardless of which middleware group the route happens to run under - and because EncryptCookies never touches this route either, the cookie travels and is read back as a plain, unencrypted string, which is also why the test below asserts on it with encrypted: false.

$response = $this->getJson('/api/orders/lookup?phone='.$order->phone_number.'&locale=es')
->assertOk()
->assertJsonPath('status_label', 'Pendiente')
->assertCookie('preferred_locale_audit', 'es', encrypted: false);

A separate test confirms a later request with no ?locale= but carrying that same cookie resolves through the strict path rather than the ordinary fallback chain - reusing 'refunded', already known from Chapter 16's own tests to be missing from lang/es/orders.php but present in lang/en/orders.php:

$this->withCredentials()
->withUnencryptedCookie('preferred_locale_audit', 'es')
->getJson('/api/orders/lookup?phone='.$order->phone_number)
->assertOk()
->assertJsonPath('status_label', 'orders.status.refunded');

If the ordinary chain had resolved this instead, the English fallback would have silently supplied 'Refunded' - the literal key coming back is the proof the cookie drove the strict path.

Worth being explicit about what this cookie is not: it only ever carries a locale preference, never anything resembling session or authorization state. A 400-day lifetime is a poor fit for data like that, which is exactly why the impersonation-adjacent cookie later in this chapter deliberately does not reach for forever().

One more attribute is worth naming precisely. Neither this cookie nor hasQueued()'s entry further on passes an explicit $secure/$httpOnly/$sameSite - both inherit CookieJar's defaults, seeded in Illuminate\Cookie\CookieServiceProvider from config('session.path')/ domain/secure/same_site. config('session.http_only') is not among them - CookieServiceProvider never reads it, so that config key is inert for this facade. httpOnly ends up true anyway only because that is CookieJar::make()/forever()'s own hardcoded parameter default, not because the config governs it.

Cookie::hasQueued() / Cookie::getQueuedCookies() / Cookie::unqueue()

Case type: a facade with a docs page covering only some of its methods - responses.md's cookie section documents Cookie::queue() (accompanying a response) and Cookie::forget() (expiring one on the next response); it says nothing about inspecting or cancelling a cookie already sitting in the queue. Alias flag: not aliases. Audience: application developers. Stability: core Cookie/CookieJar component, no churn found verifying against v13.22.0.

Minimal snippet

Cookie::queue('remembered_setting', 'value');

Cookie::hasQueued('remembered_setting'); // true

Cookie::unqueue('remembered_setting');

Cookie::hasQueued('remembered_setting'); // false

All three read or mutate the same internal queue, never the response itself:

public function hasQueued($key, $path = null)
{
return ! is_null($this->queued($key, null, $path));
}

public function unqueue($name, $path = null)
{
if ($path === null) {
unset($this->queued[$name]);

return;
}

unset($this->queued[$name][$path]);

if (empty($this->queued[$name])) {
unset($this->queued[$name]);
}
}

public function getQueuedCookies()
{
return Arr::flatten($this->queued);
}

unqueue() without a $path drops every path registered for that name; given one, it drops only that path's entry, leaving any other path's cookie of the same name queued.

Documented way vs. discovered way

There is no documented way to ask "is this cookie already queued" or "cancel a cookie I queued earlier in this same request" - queue() adds to the queue, forget() schedules an already-sent cookie's expiry on the next response, and neither looks at what is currently waiting to be sent. Once a value has been queued, the documented API offers no way back; these three are the only way to inspect or reverse that decision before the response goes out.

ImpersonationController::start() queues a cookie optimistically, then applies a business rule that decides whether to keep it - an admin cannot impersonate another admin:

public function start(User $user)
{
abort_unless(Auth::check() && Auth::user()->is_admin, 403);

Cookie::queue('impersonation_started_at', now()->toIso8601String());

if ($user->is_admin) {
// hasQueued() is unconditionally true here in this exact call path - the same key
// was just queued a few lines above with nothing in between able to clear it. The
// check is kept anyway as the defensively-correct shape for cancelling a queued
// cookie: it holds even if a future change made the initial queue() conditional, or
// if something else touched this cookie key first.
if (Cookie::hasQueued('impersonation_started_at')) {
Cookie::unqueue('impersonation_started_at');
}

abort(422, 'An admin cannot impersonate another admin.');
}

ImpersonationSession::start(Auth::user(), $user);

Route::prependMiddlewareToGroup('web', ImpersonationAuditMiddleware::class);

return back();
}

The hasQueued() check only ever matters the moment the initial queue() call stops being unconditional, or something else reaches in and touches the same key first - here, checking before cancelling is the defensively-correct shape regardless of whether this particular sequence needs it. Unlike the locale-preference cookie above, impersonation_started_at is queued with plain Cookie::queue(), not forever() - it is exactly the kind of session-adjacent signal that entry's security note warned against giving a 400-day lifetime. This route also runs under the web middleware group (unlike OrderController::lookup()'s api group in the previous entry), which keeps Laravel's default EncryptCookies and AddQueuedCookiesToResponse - so Cookie::queue() here needs no extra wiring, and the cookie is encrypted like any ordinary web-route cookie.

A second mechanism reads the queue rather than mutating it. ImpersonationAuditMiddleware records, for every audited request, the names of whatever cookies happen to be queued at that moment:

public function handle(Request $request, Closure $next): Response
{
if (ImpersonationSession::isActive()) {
ImpersonationAuditLog::create([
'admin_id' => ImpersonationSession::adminId(),
'target_user_id' => ImpersonationSession::targetId(),
'route_name' => optional($request->route())->getName(),
'queued_cookie_names' => collect(Cookie::getQueuedCookies())->map->getName()->implode(','),
]);
}

return $next($request);
}

A test starts impersonation, then visits an unrelated ticket page, and finds impersonation_started_at already sitting in that later request's audit row:

$this->actingAs($admin)->post("/admin/impersonate/{$target->id}")->assertRedirect();

$this->get("/tickets/{$ticket->id}")->assertOk();

$log = ImpersonationAuditLog::sole();
expect($log->queued_cookie_names)->toBe('impersonation_started_at');

That is not a coincidence worth glossing over. Illuminate\Cookie\Middleware\ AddQueuedCookiesToResponse attaches every queued cookie to the response but never calls CookieJar::flushQueuedCookies() afterward - the queue is never emptied on its own. Since CookieJar is a singleton, a cookie queued during start() stays queued for every later request in the same process until something explicitly unqueue()s it, which is exactly why the ticket request above still sees it. Left unmanaged under a long-running worker, the same cookie would keep re-attaching itself to every response for the rest of that worker's life.

CookieJar::queued() - singular, the method hasQueued() itself delegates to - resolves one cookie by name and path directly rather than answering yes/no; it stays out of scope here, a candidate for a future edition.

Summary

EntryDocumented alternativeWhen to prefer it
Config::getMany()Repeated single-key config() calls, or Config::get([...]) (which already delegates to it)Reading several related keys at one call site, each with its own default
Config::prepend() / Config::push()Manual read, array_merge()/array_unshift(), and Config::set()A component must extend an array-shaped config value at runtime, with its own duplicate guard
Cookie::forever()Cookie::make() with a manually computed 400-day minute countA value should persist as close to indefinitely as browsers allow, and is not session/authorization data
Cookie::hasQueued() / Cookie::getQueuedCookies() / Cookie::unqueue()None - queue()/forget() only add to the queue or expire on the next responseA decision to send a cookie needs to be inspected or reversed before the current response leaves

None of these four documented alternatives are wrong, only narrower. config()'s single-key form is still the right tool for a single value; Config::get([...]) already reaches getMany() internally the moment more than one key is worth naming together. A config array set once at deploy time never needs prepend()/push() at all - they exist for the value a component must extend after the fact, at boot, guarding against its own duplication. Cookie::make() with a hand-computed duration is correct right up until "as long as the browser will allow" becomes the actual requirement, which is what forever() exists to skip recomputing every time. And queue()/forget() cover every cookie whose fate is decided once and never reconsidered within the same request - the moment a later condition in that same request can change the decision, as it does for ImpersonationController::start()'s admin-target guard, only hasQueued()/ getQueuedCookies()/unqueue() can act on it before the response goes out.

Chapter 17 leaves Part VIII - Application Infrastructure open, not closed: Chapter 18, "Filesystem and reflection", follows next and closes it, moving from the configuration and cookie state an application carries to the files it reads and writes, and the class attributes that can drive its own behavior.