abordage/laravel-last-modified
Laravel Last Modified adds automatic last-modified tracking to your Eloquent models, keeping timestamps in sync on create, update, and delete. Use it to power HTTP caching headers, sitemap dates, or "updated" badges with minimal setup and no extra tables.
abordage/laravel-last-modified is a small HTTP-caching utility: it attaches a Last-Modified header to a response and short-circuits with 304 Not Modified when the client sends a matching If-Modified-Since. It is not a full response cache — it saves bandwidth and render time, not database queries you've already run before the middleware fires.
Minimal setup:
composer require abordage/laravel-last-modifiedphp artisan vendor:publish --tag=last-modified-config (check the README / src/ for the exact tag and class names in your installed version — this package is small and the surface area is deliberately narrow).bootstrap/app.php (->withMiddleware(...)) on Laravel 12/13, or per-route-group.First use case: a mostly-static marketing or docs page, or a read-only API endpoint backed by a table with updated_at. Point the middleware at the route, hit it twice with a browser or curl -H "If-Modified-Since: <value>", and confirm the second response is a bodiless 304.
Version note for 1.1.0: the release is compatibility and CI housekeeping only — Laravel 13 is now supported and tested, Laravel 11 is no longer in the test matrix. No new features, no deprecations, no config changes. If you are on Laravel 11, check composer.json's illuminate/* constraint before assuming you're cut off; "stopped testing" is not the same as "stopped allowing," but you are now on an untested path.
Where to look first: the published config file (it tells you what sources the package can derive a timestamp from), then the middleware class itself — it's short enough to read end-to-end and that is the fastest way to understand precedence rules.
Route-group scoping. Apply the middleware to groups, not globally. A global registration means every authenticated, personalised, session-flavoured response is a candidate for a 304, which is almost never what you want.
Route::middleware('last-modified')->group(function () {
Route::get('/docs/{page}', [DocsController::class, 'show']);
});
Timestamp from the data, not the clock. The interesting decision is what "last modified" means for a given route. Sensible sources, roughly in order of correctness:
max(updated_at) (or max(deleted_at) too, if deletions should invalidate) on the underlying tableAvoid now() or filemtime() on a file that your deploy pipeline rewrites; both cause permanent cache misses.
Pair with Cache-Control. Last-Modified on its own gives clients nothing to work with. Set an explicit Cache-Control: public, max-age=… (or no-cache if you want revalidation every time but still want the cheap 304 path). This package handles the validation half; you own the freshness half.
Model-backed endpoints. A tidy pattern for JSON APIs:
$lastModified = Article::max('updated_at');
return response()
->json($articles)
->setLastModified($lastModified ? Carbon::parse($lastModified) : null);
If the package exposes a fluent/response-macro helper, prefer that over hand-setting the header — it keeps the comparison logic in one place. If it doesn't, setLastModified() on the response plus your own If-Modified-Since check is only a few lines.
Testing. $this->get(...)->assertStatus(304) and assertHeader('Last-Modified', ...). Send the header explicitly: $this->withHeader('If-Modified-Since', $lastModified->toRfc7231String()). Freeze time ($this->travelTo(...)) so the one-second granularity of HTTP dates doesn't make the test flaky.
Integration with ETags. If you already emit ETags, Last-Modified is a weaker validator and the two coexist: clients sending both should get 304 only if both match (or per RFC 9110, the ETag takes precedence). Don't hand-roll that; if you need both, do the ETag check first and let this package handle the date path.
assertSee on a revalidated request will fail. Assert status and headers instead. Same for curl — add -i if you're confused about why "the page is empty."->min(now()) when the source is untrusted.storage/ files mutated at runtime, you'll get the opposite problem. Log your computed timestamp for a few routes in staging and sanity-check it against reality.auth, flash messages, CSRF-tokened forms, or A/B-test middleware must not be served from a shared validator. If the response varies per user, Last-Modified must reflect that variance or not be set at all.php artisan config:cache, changes to config/last-modified.php are invisible until you re-cache. Standard Laravel trap, but it bites harder on tiny packages where you're editing config during debugging.artisan route:list -v is the fastest way to confirm the middleware actually landed on the route you think it did. Half the "it's not working" reports are a group that never got the middleware.How can I help you explore Laravel packages today?