matchory/laravel-server-timing
| Install | |
|---|---|
composer require matchory/laravel-server-timing |
|
| Latest Version: | 1.3.4 |
| PHP: | ^8.2 |
| Laravel: | 10 – 13 |
| Older releases: | 8 · 9 |
| License: | MIT |
| Last Updated: | Oct 7, 2026 |
| Links: | GitHub · Packagist |
Laravel Server Timings
Add Server-Timing header information from within your Laravel apps.
Requirements
- PHP 8.3 or newer
- Laravel 12 or 13
Installation
You can install the package via composer:
composer require matchory/laravel-server-timing
Usage
Register the middleware in bootstrap/app.php. Prepend it to the global middleware stack, so it runs first and the Bootstrap and App timings
are accurate:
use Illuminate\Foundation\Configuration\Middleware;
use Matchory\ServerTiming\Http\Middleware\ServerTimingMiddleware;
return Application::configure(basePath: dirname(__DIR__))
// ...
->withMiddleware(fn (Middleware $middleware) => $middleware->prepend(ServerTimingMiddleware::class))
->create();
By default, the middleware measures only three things, to keep it as light-weight as possible:
- Bootstrap: the time from the start of the request until the middleware is called
- App: the time the application takes to return a response
- Total: the time from the start of the request until the response leaves the middleware
Once the package is installed, you can see your timing information in the developer tools of your browser. Here's an example from Chrome:

Production visibility
Timings reveal details about your application, so the header is only sent outside of production by default. To decide yourself which requests
receive it, register a callback, for example in the boot() method of a service provider:
use Illuminate\Http\Request;
use Matchory\ServerTiming\Http\Middleware\ServerTimingMiddleware;
ServerTimingMiddleware::authorize(
fn (Request $request) => app()->isLocal() || $request->user()?->is_admin,
);
The callback replaces the default check in every environment, and its result is cast to bool. It runs after the application handled the
request, so session and authentication state, like $request->user(), are available.
The callback is stored statically. If you register one in a test, remove it afterwards with ServerTimingMiddleware::flushState().
Adding measurements
Use the facade (Matchory\ServerTiming\Facades\ServerTiming), the measure_timing() helper, or inject the contract (see
Dependency injection). All durations are in milliseconds.
To measure a callable, use time(). It returns the result of the callable, and stops the event even if the callable throws:
use Matchory\ServerTiming\Facades\ServerTiming;
$users = ServerTiming::time('Load users', fn () => User::all());
The measure_timing() helper does the same:
$users = measure_timing('Load users', fn () => User::all());
To measure a section of code, use start() and stop(). Events that are still running when the response reaches the middleware are stopped
automatically.
ServerTiming::start('Running expensive task');
// do something
ServerTiming::stop('Running expensive task');
measure() starts an event if it is not running, and stops it otherwise. measure_timing() without a callable does the same, and returns
null:
measure_timing('Running expensive task');
// do something
measure_timing('Running expensive task');
If you already know the duration, set it with setDuration(). It also accepts a callable to measure, but unlike time(), it returns the
timing instance instead of the result.
ServerTiming::setDuration('Running expensive task', 1_200);
To sum up repeated work under one metric, add to its duration with addDuration():
ServerTiming::addDuration('Image processing', 35.2);
To add a metric without a duration, use addMetric():
ServerTiming::addMetric("User: {$user->id}");
Metric names
The key of an event is turned into the metric name with Str::slug() and is sent as its description, too: Load users becomes
load-users;desc="Load users". To use a short name with a longer description, use describe():
ServerTiming::describe('search', 'Search index lookup')
->time('search', fn () => $index->search($query));
Browsers merge or drop metrics with the same name, so names are unique: if two keys slug to the same name, the second one gets -2, the third
-3, and so on. A key that slugs to an empty name is sent as metric. Durations are rounded to three decimals.
Database timing
There are two built-in options that you can enable via the configuration:
measure_database: Measures the total time spent in database queries, per connection. The default connection is reported asDatabase, other connections asDatabase (name).measure_queries: Measures the time spent in each query, as individual metrics namedquery-1,query-2and so on. The description holds the SQL, cut short after 200 characters. Repeated queries are summed into one metric, and their description shows the count, likeselect * from users where id = ? (x3). Beyondmax_queriesdistinct queries (20 by default), the remaining ones are summed into a singleOther queriesmetric. Queries are never measured individually ifapp.envisproduction, even if the request is authorized.
[!WARNING] Large headers can break responses. With
measure_queriesand the defaults, the header grows to about 4.5 KB on pages with many long queries (20 metrics with up to 200 characters of SQL each). nginx buffers response headers in one memory page (often 4 KB) by default and answers with a502 Bad Gatewayif they don't fit, loggingupstream sent too big header; with 4 KB, this can happen from about 15 long queries on. Raise the buffer size together with the buffers, as nginx refuses to start if only the size is raised:# PHP-FPM fastcgi_buffer_size 16k; fastcgi_buffers 4 16k; # Octane, or any other upstream behind proxy_pass proxy_buffer_size 16k; proxy_buffers 4 16k;Alternatively, lower
max_queries.
Cache, HTTP client, queue and Livewire
Four more collectors are available, each disabled by default:
-
measure_cache: Adds aCachemetric with the number of hits and misses, likeCache (2 hits, 1 miss), and the time spent retrieving keys from the cache store. Only retrieval is timed: writes, and the callback ofremember()on a miss, are not included. -
measure_http: Adds anHTTPmetric with the number of requests sent through Laravel's HTTP client, likeHTTP client (3 requests), and the sum of their durations. Concurrent requests, like those in a pool, each count with their full duration, so the sum can exceed the wall-clock time. -
measure_queue: Adds aQueuemetric with the number of jobs pushed to a queue, likeQueued jobs (2), without a duration. Jobs run by thesyncdriver are not counted; their time is part of App. -
measure_livewire: Requires Livewire 3 or 4. Adds a metric per component with the time it took to render, likeLivewire: counter, and one per called method, likeLivewire: counter::increment. Repeated renders and calls are summed and show their count, likeLivewire: counter (x2). A component's render time includes the components nested in it.
There is no collector for plain views, as Laravel has no event for the time it takes to render one. Measure rendering yourself, if you need to:
$html = ServerTiming::time('Render', fn () => view('dashboard')->render());
Timings in logs
To include the timings in your logs, enable the context option. At the end of the request, the middleware adds the final timings to
Laravel's Context as server_timing, a map of metric keys to durations in milliseconds:
{"server_timing": {"Bootstrap": 12.4, "Database": 31.02, "App": 88.1, "Total": 100.5}}
Context is added to log entries when they are written, so only entries written after the request was measured include the timings: for
example, those written in defer() callbacks or terminating callbacks. The timings are added whether or not the request receives the header,
as they stay on the server.
Configuration
To publish the configuration file to config/timing.php, run:
php artisan vendor:publish --tag=server-timing-config
| Key | Environment variable | Default | Description |
|---|---|---|---|
enabled |
SERVER_TIMING_ENABLED |
true |
If falsy, the middleware does nothing and collectors are off |
measure_database |
SERVER_TIMING_MEASURE_DATABASE |
false |
Total database time per connection |
measure_queries |
SERVER_TIMING_MEASURE_QUERIES |
false |
Time per query, outside of production only |
max_queries |
SERVER_TIMING_MAX_QUERIES |
20 |
Distinct queries before the rest go into Other queries |
measure_cache |
SERVER_TIMING_MEASURE_CACHE |
false |
Cache hits, misses and retrieval time |
measure_http |
SERVER_TIMING_MEASURE_HTTP |
false |
HTTP client requests and the sum of their durations |
measure_queue |
SERVER_TIMING_MEASURE_QUEUE |
false |
Number of queued jobs |
measure_livewire |
SERVER_TIMING_MEASURE_LIVEWIRE |
false |
Livewire render and method call time per component |
context |
SERVER_TIMING_CONTEXT |
false |
Add the final timings to Laravel's Context for logs |
allow_origin |
SERVER_TIMING_ALLOW_ORIGIN |
null |
Value of the Timing-Allow-Origin header |
The collectors read their configuration when the application boots.
Browsers only expose server timings to scripts on other origins if the response allows it with a Timing-Allow-Origin header. Set
allow_origin to * or to an origin like https://example.com to send it along with the Server-Timing header.
Octane
The package works with Laravel Octane. All timings and collector state are reset when a request is received, so nothing leaks between requests. As the worker is already booted, Bootstrap is close to zero under Octane.
Outside of Octane, the middleware resets the timings after the response has been sent, once all other terminating callbacks have run, so a
process that handles several requests, like a test, starts each one fresh. To clear the state of your own collectors along with the timings,
register a callback with ServerTiming::onReset().
Dependency injection
To use the timings without the facade, type-hint the contract, Matchory\ServerTiming\Contracts\ServerTiming:
use Matchory\ServerTiming\Contracts\ServerTiming;
public function __construct(private readonly ServerTiming $timing) {}
To swap the implementation, bind the contract in a service provider. The middleware, the collectors, the facade and measure_timing() all
resolve the contract:
use Matchory\ServerTiming\Contracts\ServerTiming;
$this->app->singleton(ServerTiming::class, MyServerTiming::class);
Upgrading to v2
Version 2 contains the following breaking changes:
- Requirements: PHP
^8.3; Laravel 12 or 13. Support for Laravel 10 and 11 was dropped. - Header in production: the
Server-Timingheader is no longer sent in production by default. Register a callback withServerTimingMiddleware::authorize()(see Production visibility). To restore the old behaviour, send it to everyone:ServerTimingMiddleware::authorize(fn () => true). setDuration(): passingnullas the duration throws aTypeError. UseaddMetric($key)for metrics without a duration.- Final classes: all classes are
final, and the formerly protected methods ofServerTimingMiddlewareare private. Compose the classes instead of extending them. - Container bindings: the package resolves
Contracts\ServerTiminginstead of the concrete class. To swap the implementation, implement the contract and bind it (see Dependency injection). measure_timing(): returns the result of the callable, ornullwithout one, instead ofvoid.- Database metric: the default connection is reported as
Databaseinstead ofDatabase (mysql). Other connections keep their name. measure_queries: queries are namedquery-1,query-2and so on, with the SQL as description, cut short after 200 characters. Repeated queries are summed into one metric with an(xN)count, and beyondmax_queriesdistinct queries, the rest go intoOther queries. Before, a repeated query showed only its last execution.- Total: Total is the elapsed time of the request, instead of the sum of all events. Nested events, like database time, are no longer counted twice.
- Metric names: metrics whose keys slug to the same name are suffixed with
-2,-3, and so on, instead of sharing a name. Durations are rounded to three decimals. - Reset after the response: the timings are reset once the response has been sent. In tests that read them after a request, read them in
the request instead, or from Laravel's Context with the
contextoption. - Published configuration: if you published
config/timing.php, the new keysmax_queries,measure_cache,measure_http,measure_queue,measure_livewire,contextandallow_originfall back to the package defaults. Copy them from the package configuration to change them in the file.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Please see CONTRIBUTING for details.
Testing
To run unit tests, use the following command:
composer test
You can also run the type checks using the following command:
composer analyze
Security
If you discover a security vulnerability, please report it privately on GitHub instead of using the issue tracker.
Credits
License
The MIT License (MIT). Please see License File for more information.
Related Packages
Add Server-Timing header information from within your Laravel apps.
Add Server-Timing header information from within your Laravel apps.
Add Server-Timing header information from within your Laravel apps.
Version History
| Version | Released | PHP | Laravel | License |
|---|---|---|---|---|
| 1.3.4 | ^8.2 | ^10.0| | MIT | |
| 1.3.3 | ^8.2 | ^10.0| | MIT | |
| 1.3.2 | ^8.2 | ^10.0| | MIT | |
| 1.3.1 | ^8.2 | ^10.0| | MIT | |
| 1.3.0 | ^8.2 | ^10.0| | MIT | |
| 1.2.0 | ^8.2 | ^9.0| | MIT | |
| 1.1.1 | ^8.2 | ^8.0| | MIT | |
| 1.1.0 | ^8.2 | ^8.0| | MIT | |
| 1.0.2 | ^8.1 | ^8.0| | MIT | |
| 1.0.1 | ^8.1 | ^8.0| | MIT | |
| 1.0.0 | ^8.1 | ^8.0| | MIT |