In a Laravel food-ordering app, why does a middleware's terminate() method not see a start time stored in a property during handle(), and how do you fix it?
answer
- terminate(Request $request, Response $response)
- runs after send() in handleRequest()
- the kernel resolves a fresh instance
- singleton() in AppServiceProvider::register
- scoped() under long-lived workers
basics
~20 sThe kernel resolves a new middleware instance from the container before calling terminate(), so properties set in handle() are gone. Bind the class with $this->app->singleton() in AppServiceProvider::register(), or scoped() under Octane, so both calls share one instance.
solid answer
~50 sAfter `handle()` returns and `send()` has delivered the response, `Application::handleRequest()` calls the HTTP kernel's `terminate()`, which walks the route and global middleware and, for each one, calls `$this->app->make($name)` before invoking `terminate($request, $response)`. Without a binding that is a brand-new object, so `$this->startedAt` from `handle()` is null. The documented fix is `$this->app->singleton(LogSlowOrders::class)` in `AppServiceProvider::register()`, which makes the pipeline and the kernel share one instance. Under Octane, a singleton resolved while the worker boots, or warmed, is shared by every later request on that worker; `scoped()` gives one instance per request lifecycle and is flushed between Octane requests, so it is the safer binding for per-request state. Alternatives: attach the start time to the request with `$request->attributes->set()`, which `terminate()` receives anyway. Under PHP-FPM, `terminate()` runs after the client has the response, so it is the place for slow logging.
code
php · 16 lines<?php
namespace App\Providers;
use App\Http\Middleware\LogSlowOrders;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
// One instance per request lifecycle: shared by handle() and terminate(),
// flushed between requests under Octane.
$this->app->scoped(LogSlowOrders::class);
}
}go deeper
Recall that terminate(Request, Response) runs after the response is sent and that the docs pair it with a singleton binding.
Explain that the kernel resolves the middleware again before terminate(), which is why properties set in handle() are lost.
Choose between singleton(), scoped() and request attributes with long-lived workers in mind, and confirm the server flushes the response first.
Decide what post-response work belongs in terminable middleware versus queued jobs, weighing worker capacity against delivery guarantees.
## The symptom A food-ordering app logs slow requests from a terminable middleware so that the log write never delays the customer: ```php class LogSlowOrders { private ?int $startedAt = null; public function handle(Request $request, Closure $next): Response { $this->startedAt = hrtime(true); return $next($request); } public function terminate(Request $request, Response $response): void { $ms = (hrtime(true) - $this->startedAt) / 1e6; // $this->startedAt is null here } } ``` In `terminate()`, `$this->startedAt` is `null`, and the duration is nonsense. Nothing is wrong with PHP: the two methods ran on **different objects**. ## How Laravel calls terminate() The front controller `public/index.php` calls `$app->handleRequest(Request::capture())`, which does three things in order: 1. `$kernel->handle($request)` runs the global stack, the router and the route middleware, producing a response. The pipeline builds each middleware with the container's `make()`. 2. `->send()` sends headers and body to the client. 3. `$kernel->terminate($request, $response)` dispatches the `Terminating` event, then calls `terminateMiddleware()`, then the application's own terminating callbacks. `terminateMiddleware()` gathers the matched route's middleware followed by the global middleware and, for each class name, calls `$this->app->make($name)` **again**. If the resulting object has a `terminate()` method, it is called with the request and the response. With no binding, `make()` returns a new instance, so any state set in `handle()` is gone. ## The documented fix: share one instance Register the middleware as a singleton in `AppServiceProvider::register()`: ```php public function register(): void { $this->app->singleton(LogSlowOrders::class); } ``` Now the pipeline and the kernel both receive the same object, and `$this->startedAt` survives. | Binding | Instances per request | Safe under PHP-FPM | Safe under Octane | |---|---|---|---| | None (default) | Two: one for `handle()`, one for `terminate()` | Yes, but state is lost | Yes, but state is lost | | `singleton()` | One, shared | Yes: the process serves one request | Risky: an instance resolved at worker boot or warmed is shared by later requests | | `scoped()` | One per request lifecycle | Yes | Yes: flushed between requests | ## The long-lived worker trap Under PHP-FPM the whole container is rebuilt for every request, so a singleton lives exactly as long as the request. **Octane** keeps a booted application in memory and gives each request a clone of that container. A singleton first resolved during a request lives in that clone and is discarded with it, but a singleton resolved while the worker boots, or listed in Octane's `warm` configuration, sits in the base container and is shared by every request the worker serves. If that happens to a stateful middleware, a request that skips `handle()` can compute a duration from an earlier customer's start time. The container's `scoped()` binding resolves once per request or job lifecycle and is explicitly flushed when Octane starts a new request, so it gives the sharing without depending on when the class was first resolved. ## A stateless alternative The request object is passed to both methods, so the start time can travel with it and the middleware needs no binding at all: - In `handle()`: `$request->attributes->set('order_timer', hrtime(true));` - In `terminate()`: read it back with `$request->attributes->get('order_timer')`. Under PHP-FPM, `public/index.php` also defines `LARAVEL_START` at the very beginning of the request, which `terminate()` can compare against `microtime(true)` for a whole-request duration. Octane workers do not run `public/index.php` per request, so do not rely on that constant there. ## When terminate() actually runs relative to the client The documentation's condition is **FastCGI**: when PHP runs under PHP-FPM, the response is flushed to the client before `terminate()` runs, so the customer is not kept waiting for the log write. On other server setups the connection may stay open until termination work finishes, which removes the benefit. Termination also runs only middleware the kernel can gather: route middleware for the matched route and the global stack. ## terminate() versus other post-response hooks Terminable middleware is the right tool when the post-response work is tied to a middleware that already wraps the request, such as timing or auditing. It runs synchronously in the same PHP worker, so while it runs, that worker cannot serve another request. Heavy work, such as sending a receipt email for the order or recalculating delivery estimates, belongs on a queue instead, where it can be retried and does not hold a web worker. ## Checklist - Put work that must not delay the user, such as slow-request logging, in `terminate()`. - If `terminate()` needs state from `handle()`, share it through `scoped()`, or better, through the request. - Never keep per-request state on a plain singleton in an app that may run under Octane.
- Why prefer scoped() over singleton() for the terminable middleware if the app may run under Octane?Under PHP-FPM both behave the same, because the container dies with the request. Under Octane, a singleton that was resolved during worker boot or warmed lives in the base container and is shared by later requests, while `scoped()` instances are flushed at the start of every request. `scoped()`, or keeping the state on the request, removes that dependency on resolution timing.
- Does terminate() run for a middleware attached to a route when the request ends in a 404?No. `terminateMiddleware()` gathers route middleware from the matched route; with no matched route there are none, and only the global middleware's `terminate()` methods are called.
- In what order are terminate() methods called?The kernel builds one list, the matched route's middleware followed by the global middleware, and calls `terminate()` in that order on each class that defines it. This happens after the `Terminating` event is dispatched and before the application's own terminating callbacks run.
A waiter serves your meal, and later a busser clears the table. Unless the restaurant assigns one person to your table for the whole visit, the busser is someone new who never heard what you ordered. Binding the middleware as a singleton or scoped instance is assigning that one person.
saying these in an interview costs you the question
- Assumes handle() and terminate() always run on the same instance
- Believes singleton() and scoped() are always identical under Octane
- Believes terminate() runs before the response is sent
- Thinks terminate() receives only the request, not the response
- Relies on LARAVEL_START inside an Octane worker