dragon-code/last-modified
A small Laravel package for tracking last-modified timestamps and exposing them through HTTP headers, helping browsers, proxies, and caches avoid unnecessary re-downloads and stay in sync with your content.
Install and confirm discovery
composer require dragon-code/last-modified
The package is a standard Laravel service provider with auto-discovery; verify it registered by checking php artisan about or php artisan route:list -v after wiring the middleware. As of 4.4.0 the package supports Laravel 13 (alongside the 10/11/12 range), so registration is unchanged between versions — but there is a published config file, so run php artisan vendor:publish --provider="DragonCode\LastModified\ServiceProvider" and confirm the exact tag/provider name against the repo README.
Where to look first, in order
config/last-modified.php — the enable/disable switch, cache/TTL settings, and any header strategy option. This is the only file you should need to touch.src/Middleware/...). Read it once, end to end — it's small. You need to know exactly what it hashes and which request headers it honours before you trust it in front of production traffic.ETag, Last-Modified, 304 with empty body).First use case
The natural first target is a read-heavy, publicly cacheable route — a marketing page, a sitemap, a /api/v1/catalog endpoint, or a public RSS feed. Register the middleware on a route group, hit the URL twice with curl, and confirm the second call returns 304:
curl -i https://app.test/api/v1/catalog
curl -i -H 'If-None-Match: "<etag-from-first-response>"' https://app.test/api/v1/catalog
If you see HTTP/1.1 304 Not Modified with no body and the same ETag, the package is doing its job. If you see 200 both times, the response is not being recognised as stable (dynamic timestamps, CSRF tokens, session-dependent content) — see Gotchas.
Scope the middleware to a route group, not globally. Global registration is the tempting shortcut, but any response containing per-user data (session ID in a cookie, a cart count, a CSRF token, a flash message) will produce a different hash on every request and you'll get 200s forever — or worse, a stale 304 served to the wrong user. Start narrow:
Route::middleware('last-modified')->group(function () {
Route::get('/catalog', [CatalogController::class, 'index']);
Route::get('/sitemap.xml', SitemapController::class);
});
Register the alias in bootstrap/app.php (Laravel 11+, including 13) or app/Http/Kernel.php::$routeMiddleware (Laravel 10 and below).
Drive the validator from your data, not the rendered HTML. Content-hashing the whole response is the package's default behaviour and it works, but it costs a full string hash per request and it breaks the moment anything nondeterministic creeps into the view. The better pattern is to bind the validator to something cheap and meaningful:
max(updated_at) as the Last-Modified value and let the model layer be your source of truth.filemtime() or a deploy-stamped version string as the ETag seed.meta.version or a Cache-Control paired ETag.Read the middleware's extension points before writing your own — if it exposes a resolver/callback interface, use it; if not, subclass or wrap.
Pair it with the framework cache, not instead of it. last-modified only saves bandwidth and serialization; the controller still runs. The winning combination is last-modified in front of a Cache::remember() in the controller, so a 304 is served after a cheap cache hit rather than after a full query. Do not put it in front of spatie/laravel-responsecache and expect the two to cooperate cleanly — pick one as the authority for the ETag header.
Explicitly exclude methods and routes. Only GET/HEAD are valid for conditional requests. If the middleware is in a broad group, add a guard (or a route ->withoutMiddleware()) for POST/PUT/PATCH routes so a stray If-None-Match from a weird client can't turn a write into a 304.
Test it as a contract, not an implementation detail.
$first = $this->get('/catalog')->assertOk();
$etag = $first->headers->get('ETag');
$this->withHeader('If-None-Match', $etag)
->get('/catalog')
->assertStatus(304)
->assertSee(''); // body must be empty
Also assert that Last-Modified is present, is in RFC 7231 format, and that the 304 response repeats the same ETag/Cache-Control headers.
4.4.0 is a compatibility-only release — no behaviour change. It adds Laravel 13 support; the rest is LICENSE/README/banner housekeeping. There is no middleware, config or header change, so your existing ETag/304 contract tests stay valid and you do not need to re-baseline assertions. Re-run the suite against your Laravel 13 upgrade and treat the middleware as unchanged.
A 304 must have no body. Symfony's Response::setNotModified() handles this, and the package presumably uses it — but if you write a custom path or override the response, an empty-string body is not the same as no body. Assert on it in tests.
If-None-Match wins over If-Modified-Since. Per RFC 9110, a server must ignore If-Modified-Since when If-None-Match is present. If you're extending the middleware, don't accidentally evaluate both and return 200 when the ETag matches but the date doesn't — browsers and proxies will see inconsistent behaviour across clients.
Timestamps and timezones. Last-Modified is a GMT/UTC HTTP-date, not your app timezone. If you feed it an updated_at that's been mutated to Europe/Moscow, the validator will always look "changed" or always look "unchanged" depending on the drift. Convert to UTC before formatting, and remember HTTP dates have one-second granularity — a resource that updates twice in the same second is invisible to Last-Modified (ETag handles it, so prefer ETag for fast-changing data).
Session and auth middleware break the hash. The middleware must run after the response is fully generated but before StartSession/VerifyCsrfToken/ShareErrorsFromSession mutate it — or, more practically, you must exclude responses where session state changes the output. If your web group is in play, test with a logged-in user, a fresh guest, and a user with a flash message, and confirm none of them get each other's 304s. If the ETag matches across users, you have a cache-poisoning bug, not a caching win.
Reverse proxies will strip or rewrite headers. Nginx's proxy_cache and most CDNs recalculate ETags on gzip'd bodies. Validate the behaviour end-to-end through your load balancer (curl --compressed), not just against the dev server, and set Vary: Accept-Encoding, Cookie if either varies the payload.
Debugging checklist when you always get 200: dump the response body inside the middleware and diff two consecutive requests. The usual culprits are @csrf in the layout, now() rendered into a footer, a session-generated _token, or a random cache-busting asset query string. Fix the nondeterminism — don't disable the package.
Track the release line, but keep the wrapping escape hatch. 4.4.0 shows the package is still receiving framework-compatibility updates, so the earlier "frozen forever" risk is lower than it looked — constrain to ^4.4 and let patch/minor releases bring Laravel 14 compatibility when it lands. That said, the release cadence is compatibility-driven, not feature-driven: if you need non-standard behaviour (custom hash algorithm, Redis-backed ETag store, HEAD handling), still write your own ~30-line middleware calling $response->setNotModified() with a Cache::remember() around the hash. Owning that logic keeps you on a supported path and lets you drop dragon-code/last-modified without a refactor.
Still pin and review on upgrade, even though it's maintained. Laravel 13 support landing in 4.4.0 means the package tracks framework releases, but the middleware/kernel registration surface has changed across 10→11→13. Lock the version in composer.json, run the 304 contract tests in CI as part of every framework bump, and re-read the middleware after any major Laravel upgrade rather than assuming the compatibility bump covered your route group.
How can I help you explore Laravel packages today?