In Laravel, how do Context::addHidden() and the Context::dehydrating() and hydrated() callbacks let a queued job restore request state such as the locale without logging it?
answer
- hidden data skips the log processor
- getHidden, not get
- dehydrating edits the copy sent to the job
- hydrated runs before handle in the worker
- register both in AppServiceProvider boot
basics
~20 sContext::addHidden() stores values that travel into queued jobs but are never appended to log records. A Context::dehydrating() callback adds request state such as app.locale to the copy sent with the job; a Context::hydrated() callback restores it in the worker.
solid answer
~40 sHidden context is a second store beside normal Context: `addHidden()`, `getHidden()`, `pushHidden()` and friends mirror the normal methods, `get()` does not see it, and the log processor only appends `Context::all()`, so it never reaches log lines. `Context::dehydrating(fn (Repository $context) => ...)` runs when a job payload is built; it receives a copy of the repository, so you can add, say, `$context->addHidden('locale', Config::get('app.locale'))` without changing the current process. `Context::hydrated(fn (Repository $context) => ...)` runs in the worker once the payload's Context is restored, before `handle()`, and can call `Config::set('app.locale', $context->getHidden('locale'))`. Both are registered in `AppServiceProvider::boot()`. Inside them, change the passed repository, not the `Context` facade. Hidden values are still stored unencrypted in the payload.
code
php · 24 lines<?php
namespace App\Providers;
use Illuminate\Log\Context\Repository;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Context;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
Context::dehydrating(function (Repository $context) {
$context->addHidden('locale', Config::get('app.locale'));
});
Context::hydrated(function (Repository $context) {
if ($context->hasHidden('locale')) {
Config::set('app.locale', $context->getHidden('locale'));
}
});
}
}go deeper
Recall that Context has a hidden variant whose values are not written to logs and that you read them with getHidden().
Explain the dehydrating and hydrated callbacks, where they run, and why the dehydrating one receives a copy of the repository.
Use the hooks to carry locale or tenant state into workers, and point out that hidden values sit unencrypted in queue payloads, so secrets stay out.
Set a rule for which request state is allowed to cross into jobs and how, balancing implicit propagation against explicit job constructor arguments.
## Two stores in one repository Laravel's `Context` facade is backed by `Illuminate\Log\Context\Repository`, which keeps two arrays: - **data**, filled by `add()`, `addIf()`, `push()`, `increment()` and read by `get()`, `all()`, `only()`; - **hidden**, filled by `addHidden()`, `addHiddenIf()`, `pushHidden()`, `rememberHidden()` and read by `getHidden()`, `allHidden()`, `onlyHidden()`. The two never mix: after `Context::addHidden('key', 'v')`, `Context::get('key')` returns `null`. The log processor that decorates every record appends only `all()`, so hidden values never appear in log output. The value parameter of `addHidden()` also carries PHP's `#[\SensitiveParameter]` attribute, so a stack trace through that call shows a placeholder instead of the value. Both stores are **dehydrated into queued job payloads** and **hydrated in the worker**, which is what makes hidden context useful: a job can read request-derived data that you do not want in logs. ## The lifecycle hooks Context dispatches two events around the queue boundary, and the facade gives each a registration method: | Method | Runs | Receives | Typical use | |---|---|---|---| | `Context::dehydrating($cb)` | while a job payload is built, in the dispatching process | a **copy** of the repository | capture process state (config, locale, tenant) into the payload | | `Context::hydrated($cb)` | in the worker, after the payload's Context is restored and before `handle()` | the worker's live repository | apply that state to the worker process | The dehydrating copy matters: `Repository::dehydrate()` builds a new instance with the current data and hidden values, dispatches the event with it, then serialises it. Anything you add inside the callback goes only to the job. The docs warn against using the `Context` facade inside either callback and say to change only the repository passed in; for `dehydrating` in particular, the facade would change the dispatching process's live context instead of the copy bound for the job. ## A worked example: the locale A middleware sets `app.locale` from the `Accept-Language` header. A queued notification sent later would render in the worker's default locale, because config changes in the web process do not travel. The fix: 1. In `AppServiceProvider::boot()`, register `Context::dehydrating()` to copy `Config::get('app.locale')` into hidden context. 2. Register `Context::hydrated()` to check `hasHidden('locale')` and call `Config::set('app.locale', ...)`. 3. Every queued job, mail and notification now runs with the dispatching request's locale, and nothing about it appears in logs. The same pattern carries a tenant id, a feature-flag scope or an impersonation marker. ## What hidden does not mean - **Not encrypted.** Values are `serialize()`d into the payload key `illuminate:log:context` in plain form. `ShouldBeEncrypted` encrypts the serialised job command, not that key. Anyone who can read the queue backend can read hidden context. - **Not invisible to your own code.** Any code in the process can call `Context::getHidden()`. - **Not free of serialisation rules.** Models are stored as identifiers and re-queried; a deleted model is reported and restored as `null`. So hidden context is for values that must not appear in **logs**, such as a user's locale or an internal id. It is not a store for passwords, card data or API secrets. ## Hidden context or a job argument? | Option | Visible in logs | Reaches the job | Best for | |---|---|---|---| | job constructor property | only if you log it | yes, explicitly | data the job's logic depends on | | `Context::add()` | yes, under `extra` | yes, implicitly | correlation ids and breadcrumbs | | `Context::addHidden()` | no | yes, implicitly | cross-cutting state such as locale or tenant | Data the job cannot work without belongs in its constructor, so the dependency is visible and testable. Hidden context suits values that every job should inherit without each class declaring them. ## Checklist 1. Register both callbacks once, in a service provider's `boot()`. 2. Change only the repository passed to the callback. 3. Guard `hydrated` with `hasHidden()`, because jobs dispatched before the callback existed carry no value. 4. Keep hidden values small; they are copied into every payload.
- Why must a dehydrating callback change the passed repository instead of calling Context::addHidden()?The callback receives a copy that is about to be serialised into the payload. Calling the facade writes to the current process's live context, so the value lands in this request's state (and in jobs dispatched later) but not in the copy already being built for this job. Changing the passed repository affects only what the job receives.
- A job implements ShouldBeEncrypted. Is its hidden context encrypted too?No. `ShouldBeEncrypted` makes the queue encrypt the serialised job command, while Context is added by a separate payload hook under `illuminate:log:context` and stored serialised but unencrypted. Keep real secrets out of Context, or pass an identifier and fetch the secret inside the job.
saying these in an interview costs you the question
- Hidden context is encrypted in the job payload.
- Context::get() returns hidden values when no normal value exists.
- Calling the Context facade inside a dehydrating callback is the documented way to add data.
- Config changes made in a request automatically apply in queued jobs.
- Hidden context is dropped at dispatch, so jobs cannot read it.