guhemama/http-precondition-bundle
Symfony bundle for handling HTTP precondition headers such as If-Match, If-None-Match, If-Modified-Since, and If-Unmodified-Since. Helps controllers return correct 304 or 412 responses and enforce optimistic concurrency without repetitive header logic.
Reality check first: this is a Symfony HttpKernel bundle, not a Laravel package. As a Laravel developer the practical value is split two ways: (1) if you maintain a Symfony/standalone-HttpKernel service, use it directly; (2) if you're evaluating it for a Laravel app, treat it as a reference implementation of RFC 7232 preconditions and port the ~60 lines into middleware. The good news is that port is nearly free, because Illuminate\Http\Request and Illuminate\Http\Response both extend their Symfony counterparts — setEtag(), setLastModified(), and $request->isNotModified($response) already exist in your app today.
Install and register (Symfony side). Note the 1.0.1 rename: the bundle class now follows Symfony's vendor-prefixed convention:
composer require guhemama/http-precondition-bundle
// config/bundles.php
return [
// ...
Guhemama\HttpPreconditionBundle\GuhemamaHttpPreconditionBundle::class => ['all' => true],
];
If you were on 1.0.0, the class was registered without the vendor prefix (Guhemama\HttpPreconditionBundle\HttpPreconditionBundle). 1.0.1 also reworked the configuration tree, so any config/packages/*.yaml using the old root key will fail at container compile — re-read Configuration.php and adjust the key before upgrading.
Where to look first, in this order:
README.md — the only real spec for a 2-star package; assume it's incomplete, and assume it lags the 1.0.1 config changes.src/DependencyInjection/Configuration.php — the config tree is the honest API surface, and it is exactly what 1.0.1 changed. Whatever you can't find in the README is defined here.kernel.request or kernel.controller), and it tells you the exact HTTP semantics implemented.composer.json — check the symfony/http-kernel constraint before anything else.First use case: a conditional GET on an expensive read endpoint. Your controller returns a resource plus a validator (ETag or Last-Modified); the bundle compares it against If-None-Match / If-Modified-Since and short-circuits to 304 Not Modified before your controller body runs. The mirror case is a conditional write: If-Match on a PUT/PATCH/DELETE, answering 412 Precondition Failed when the client's ETag is stale — optimistic concurrency for free.
The Laravel equivalent you can ship today, without the bundle:
// app/Http/Middleware/HandlePreconditions.php
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
if (! $response->getEtag() && ! $response->getLastModified()) {
return $response; // nothing to validate against
}
if ($request->isMethodCacheable() && $request->isNotModified($response)) {
return $response->setNotModified();
}
return $response;
}
// routes/api.php
Route::get('/reports/{report}', ReportController::class)
->middleware('precondition');
Validators belong on the resource, not the response body. The dominant mistake with precondition packages is hashing the rendered body to produce an ETag. That's a full render plus a hash, and it defeats the point of short-circuiting. Derive the validator from cheap, monotonic state:
$response->setEtag(md5($report->updated_at->timestamp . ':' . $report->version));
$response->setLastModified($report->updated_at);
$response->setPublic();
$response->setMaxAge(0);
$response->headers->addCacheControlDirective('must-revalidate');
Split safe vs. unsafe semantics deliberately. These are two different features wearing one name:
| Method | Request header | Failure response | Purpose |
|---|---|---|---|
GET/HEAD |
If-None-Match, If-Modified-Since |
304 |
Bandwidth / render savings |
PUT/PATCH/DELETE |
If-Match, If-Unmodified-Since |
412 |
Lost-update prevention |
Most implementations of this pattern drift toward only handling the 304 side. Verify in the listener whether strong comparison (If-Match) is implemented; if not, treat concurrent writes as out of scope and use a DB-level version column or updated_at guard instead.
Register the check as a route-level opt-in, not globally. Preconditions on an endpoint that returns Cache-Control: no-store (dashboards, per-user feeds) are pure overhead and risk leaking existence of resources. In Symfony, attach via route defaults/options; in Laravel, use a named middleware group so only read-heavy, cacheable, and idempotent-write routes get it.
Order against security. Preconditions must run after authentication/authorization, otherwise a 412/304 tells an unauthenticated caller that a resource exists and roughly what its version is. On kernel.controller you're safely after the firewall; on kernel.request you are not, unless the listener's priority places it there.
Pair with a shared cache only if you set the right headers. The bundle makes revalidation cheap; it does not make caching happen. Without Cache-Control: public, must-revalidate (or a no-cache directive), no intermediary will ever send If-None-Match and your 304 branch is dead code.
Testing pattern — the highest-signal tests for this feature are two-liners, and you should write them even if you don't write anything else:
$response = $this->get('/reports/1');
$etag = $response->headers->get('ETag');
$this->get('/reports/1', ['If-None-Match' => $etag])
->assertStatus(304);
$this->put('/reports/1', [], ['If-Match' => '"stale"'])
->assertStatus(412);
Integration with a full-page cache: if you run Symfony HttpCache or a reverse proxy in front, the precondition listener is largely redundant for GET — HttpCache already evaluates If-None-Match against stored responses. Use the bundle at the application layer only for resources you deliberately don't cache (personalized but still revalidatable) or for the write-side If-Match guards, which no cache handles for you.
config/bundles.php breaks, and it's easy to misread as harmless. Pin to 1.0.1 explicitly and diff your bundles.php and config/packages/ against the release before upgrading. The config tree rework means stale YAML silently becomes an unknown-key error at container compile, not at runtime — run bin/console cache:clear immediately after upgrading so you fail fast.symfony/http-kernel constraint in composer.json — if you're on Symfony 7 and the constraint stops at ^6.x, composer require will fail or force a downgrade. Budget for a fork.If-Match, If-None-Match, If-Modified-Since are free-text on the wire). Read the comparison logic yourself; don't assume it validates header parsing.If-Match don't mix. W/"abc" is valid for If-None-Match but must fail strong comparison for If-Match. If the implementation uses loose == instead of a strong-comparison check, clients with proxies that downgrade ETags will get spurious 412s — and worse, a stale write can slip through.If-None-Match: * means "return 304 if the resource exists at all." It's easy to miss and a common source of a 304 on a resource the client has never seen.If-Modified-Since must be compared at second granularity, and any Last-Modified later than Date is invalid. Sub-second updated_at values cause flapping 304s.304 responses must have no body. Symfony strips it, but if you manually build the response you can break clients. Always use setNotModified().curl, not the browser. Browsers silently add If-None-Match and hide the 304; you'll chase ghosts. Use curl -v -H 'If-None-Match: "x"' https://… to see exactly which branch fires. If nothing fires, the cause is usually priority ordering or the listener not being registered for that route.setEtag()/setLastModified() on the response; you keep Laravel's middleware ordering, testing helpers, and route groups, and you drop a dormant third-party dependency whose last two releases were a 2023 fix and a naming cleanup. Port it, cite the README as the spec, and move on.How can I help you explore Laravel packages today?