In Laravel 13, what does the #[BindWhen] container attribute do, why does it need PHP 8.5, and when is its condition evaluated?
answer
- added in laravel/framework 13.22.0
- closure in attribute argument: PHP 8.5
- condition receives the container
- a match becomes an ordinary binding
- unmatched conditions re-checked since 13.24.0
basics
~20 s#[BindWhen(BetaEventPusher::class, static fn () => ...)] on an interface binds that class only when the closure returns true. Closures in attribute arguments need PHP 8.5. The condition runs at resolution, and once it matches the result is registered as an ordinary binding.
solid answer
~50 s`#[BindWhen]`, added in laravel/framework 13.22.0, takes a concrete class and a closure. When the container first resolves the interface, it calls the closure with the container and uses the class if the closure returns `true`; `#[Bind]` and `#[BindWhen]` attributes are evaluated in declaration order, with a wildcard `#[Bind]` as the fallback. The closure sits inside an attribute argument, which PHP allows only from 8.5, and it must be static, so the attribute fails to compile on PHP 8.3 or 8.4 even though Laravel 13 runs there. The trap is timing: a match is written into the bindings table like a provider `bind()`, so the condition is not asked again for the life of that application instance. Under PHP-FPM that is one request; in a queue worker the first job's answer sticks for every later job. Since 13.24.0 an unmatched condition is re-evaluated next time, but a wildcard fallback is registered permanently too.
code
php · 20 lines<?php
namespace App\Providers;
use App\Contracts\EventPusher;
use App\Services\BetaEventPusher;
use App\Services\StableEventPusher;
use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// Re-evaluated on every resolution, unlike #[BindWhen].
$this->app->bind(EventPusher::class, fn ($app) => Feature::active('beta-events')
? $app->make(BetaEventPusher::class)
: $app->make(StableEventPusher::class));
}
}go deeper
Recall that #[BindWhen] binds a class when a closure returns true, and that the closure in an attribute needs PHP 8.5.
Explain declaration order with #[Bind], the wildcard fallback, and that a match is registered as an ordinary binding.
Spot per-user or per-request conditions that will stick in queue workers, and replace them with closure bindings or a strategy object.
Decide which runtime switches may live in declarative attributes and which must stay in code that runs per call, and document it for the team.
## What #[BindWhen] adds `#[Bind]` chooses an implementation by **environment**. `#[BindWhen]`, added to `Illuminate\Container\Attributes` in laravel/framework **13.22.0**, chooses by an **arbitrary condition**: ```php #[BindWhen(BetaEventPusher::class, static fn () => Feature::active('beta-events'))] #[Bind(StableEventPusher::class)] interface EventPusher {} ``` The attribute is repeatable and targets classes, which includes interfaces. Its closure may accept the container as its argument and must return a boolean. ## Why it needs PHP 8.5 Attribute arguments are **constant expressions**, evaluated when the attribute is instantiated. Before PHP 8.5, a closure was not a legal constant expression, so the code above is a compile-time error. PHP 8.5 added closures and first-class callables in constant expressions, with two restrictions enforced by the compiler: - the closure must be **`static`**, since there is no object to bind `$this` to; - it cannot capture variables with **`use (...)`**. Laravel 13 itself requires PHP 8.3 or later, so an app on 8.3 or 8.4 can run Laravel 13 but cannot write a `#[BindWhen]` with a closure. The Laravel documentation states the PHP 8.5 requirement explicitly. ## When the condition runs When something resolves `EventPusher` and no explicit binding exists: 1. The container walks the interface's `#[Bind]` and `#[BindWhen]` attributes in declaration order. 2. For each `#[BindWhen]`, it calls the closure with the container. The first one returning `true` wins. 3. A `#[Bind]` matching the environment also wins; a wildcard `#[Bind]` is kept as the fallback. 4. The winner, or the fallback, is **registered as an ordinary binding** (shared if the interface also has `#[Singleton]`). 5. If nothing matched and there is no fallback, resolution fails; since **13.24.0** the attributes are re-evaluated on the next attempt instead of being skipped. Step 4 is the important one. Once registered, the binding sits in the bindings table, which the container checks before attributes. **The condition is not called again** for that application instance. ## The stickiness trap | Runtime | How long a decision lasts | |---|---| | PHP-FPM | One request, since each request boots a new application | | Queue worker | Every job the worker processes after the first resolution | | Artisan command | The command's run | | Unit test | Until the application is rebuilt for the next test | So a condition that depends on **who is asking**, such as a per-user feature flag, is evaluated for whoever triggered the first resolution. In a queue worker, the first job's user decides the implementation for all later jobs. The same applies when the condition is false and a wildcard `#[Bind]` supplies the fallback: the fallback is registered permanently, and the `#[BindWhen]` is never consulted again. ## Reading an attribute stack Given the stack `#[BindWhen(Beta::class, $cond)]`, `#[Bind(Stable::class)]`, `#[Bind(Fake::class, environments: 'testing')]`, the first resolution produces: | Environment | `$cond` | Resolves to | Condition asked again? | |---|---|---|---| | production | true | `Beta` | No | | production | false | `Stable` (wildcard fallback) | No | | testing | false | `Fake` (environment match) | No | | testing | true | `Beta` (declared first) | No | Every row ends in a registered binding, which is why the last column never changes. Only a stack with no fallback and no match leaves the door open for a later re-evaluation. ## Safer patterns for per-call decisions - Keep `#[BindWhen]` for **process-wide** switches whose answer cannot change during the process's life, such as a config toggle or an installed-extension check. - For decisions that vary per request, job or user, bind a **non-shared closure** in a provider, `bind(EventPusher::class, fn () => Feature::active('beta-events') ? new BetaEventPusher : new StableEventPusher)`, which runs on every resolution. - Or inject a small **strategy** object that checks the flag each time it is called, so no container state holds the decision.
- In Laravel 13, an interface has #[BindWhen(Beta::class, ...)] followed by #[Bind(Stable::class)] and the condition is false at first resolution; does a later true condition ever switch it to Beta?Not in that application instance. The wildcard `#[Bind]` supplies `Stable` as the fallback, which the container registers as an ordinary binding, and bindings are checked before attributes. The 13.24.0 re-evaluation only applies when nothing at all matched.
- In PHP 8.5, what restrictions apply to a closure written inside an attribute argument?It must be declared `static`, and it cannot capture outer variables with `use (...)`; the compiler rejects either. It can still call static methods and facades, which is how Laravel's documentation writes its `#[BindWhen]` example.
saying these in an interview costs you the question
- #[BindWhen] re-runs its condition on every resolution, like a closure binding.
- #[BindWhen] works on PHP 8.3 because Laravel 13 supports PHP 8.3.
- A queue worker flushes attribute bindings between jobs.
- #[BindWhen] outranks every #[Bind], whatever the declaration order.
- A non-static closure with use() is fine inside an attribute argument in PHP 8.5.