Releases: expertsystemsau/laravel-conditional-requests
Release list
v1.0.1
Fixed
Documentation only. No behaviour changed, and no published symbol moved — the
v1.0.0 code is unaffected.
-
H6 prescribed a remedy that cannot run. It told the
reader to setCache-Controlfrom middleware declared insideconditional.
On the path H6 is about — amodelroute whose cached entry successfully
revalidates — that middleware never executes:handle()returns the
short-circuited304before it calls$next($request), so nothing declared
after this middleware is entered at all. The advice named the one position
where the header is guaranteed not to appear.Position was never the discriminator. The outside middleware does run and
does see the304on the way out; Laravel'sSetCacheHeadersloses the header
because it returns early on a contentless response, not because of where it
sits. The remedy is an outside middleware that sets the header
unconditionally, or keeping a cache-policy route offmodel. Inside remains
correct for a304decided after the controller ran —body, ormodel
whenfromRequest()declined — whereattach()marks the real response and
setNotModified()leavesCache-Controlalone. The two cases are now stated
separately rather than collapsed into one claim about ordering. -
docs/placement.mdcarried the same inversion twice, under "What must run
insideconditional" and in its closing nuance. H7 was
already correct and says explicitly that ordering does not help undermodel;
H6 and this page now agree with it. -
docs/api.mdundercounted the API-freeze examples. It said "Four further
examples" inPublicApiTest.phpand listed four; there are five. The missing
one freezes the strategy contract inheritance chain, and it is the only thing
asserting that chain — the signature snapshot records noextends
relationships — for a promise the same page makes a hundred lines earlier.
v1.0.0
Added
The middleware and its flags
conditionalroute middleware, implementing RFC 9110 §13 conditional requests on both the read and the write path.- Order-independent middleware flags: a strategy name, plus the reserved words
requiredandlock, both of which imply themodelstrategy because the current validator has to be known before the controller runs. Http\Middleware\Conditionalis registered as theconditionalalias on every boot, and works as route, group, or kernel-global middleware.
Validator strategies and the registry
bodystrategy: a hash of the rendered response, working on any route with no model changes. Saves bandwidth, not compute.modelstrategy: a strong validator derived from the route-bound record's own version — the connection's database name, the connection's table prefix, the table, the key, and either an explicitversioncolumn or the rawupdated_atvalue. Saves bandwidth and compute.ValidatorStrategy,RequestValidatorStrategyandLockableValidatorStrategycontracts, so an application can register its own strategy under any name and use it as a middleware flag.ConditionalRequestsstrategy registry, resolved from the container and extended from a service provider'sboot().ProvidesConditionalValidatorcontract andHasConditionalValidatortrait, withconditionalVersionColumns()andconditionalLastModifiedColumn()as the documented extension points.Validatorvalue object: a bare entity tag, a weakness flag, and an optional modification instant floored to the whole second in UTC. It rejects a tag that cannot appear inside a quoted entity tag — empty, or containing a double quote, a control character, or a comma, the last of which would split the tag across two members of anIf-Matchlist and pin the resource at412.
The read path
ETagattached to eligible 2xx responses, and304 Not Modifieddecided by Symfony's ownResponse::isNotModified(), including tag lists and the bare*wildcard.- Pre-controller
304short-circuit under any strategy that can answer from the request: a matchingIf-None-Matchon a model-derived route never executes the route action. - The short-circuit is refused to a client that has demonstrated nothing. A bare
If-None-Match: *matches every validator there is, and a loneIf-Modified-Sincecan be guessed, so neither is answered before the controller — behind a gate declared afterconditional, either would have made the status code an existence oracle. Both take the ordinary path and get the same304at the end. A wildcard or a date sent alongside a tag that matches still short-circuits. - Eligibility rules skipping unsuccessful, already-tagged, streamed, binary, oversized, and empty-bodied responses — with the streamed, binary and size rules suppressed when the strategy already derived a validator from the request, because that one cost no body read.
- A
304the middleware produces is prepared, so it leaves without aContent-Typeof PHP's own choosing under kernel-global placement, where nothing else re-prepares it. HEADsupport: the request is presented to the controller as aGETonly where a body hash is actually needed and a route has already been resolved, and the response body is emptied again on the way out.
Last-Modified
Last-Modifiedon model-derived responses, alongside theETag, withIf-Modified-Sinceanswered on the read path.- A date is published only once the second holding the change has elapsed. RFC 9110 §8.8.2.2 permits a date validator to be treated as strong only when the server knows the representation did not change twice inside the second it names, which is unknowable while that second is still running — so a record that has just changed carries its
ETagalone until the second is over. - Attaching a date never changes what a response says about caching: Symfony would otherwise recompute an unset
Cache-Controlinto one permitting heuristic freshness, and the middleware puts the original back.
The write path
If-Matchevaluated with strong comparison before the controller runs, refusing a stale write with412 Precondition Failed— on every unsafe method, not onlyPATCH. A blankIf-Match, andIf-Match: W/*, are both refused rather than treated as absent.requiredflag, answering an unsafe request that carries no precondition with428 Precondition Required. It is satisfied by anIf-Match, byIf-None-Match: *, or by a validIf-Unmodified-Since— and never by a concreteIf-None-Match, which states no version the client believes it is writing over.If-None-Match: *create guard, so two clients racing to create the same resource produce one success and one412. It writes only on a definite absence, asked of the strategy throughtargetExists()separately from the version, so a strategy that cannot tell fails closed.If-Unmodified-Sinceon the write path, at RFC 9110 §13.2.2's precedence position betweenIf-MatchandIf-None-Match. A resource that publishes no date refuses it with412rather than ignoring it.- A precondition the route's strategy cannot evaluate is refused with
412rather than discarded, so a client is never told a guard passed when it was never applied. A kernel-global instance defers that refusal to the route, which is what keeps the guards on the routes underneath it working. PreconditionFailedExceptionandPreconditionRequiredException, both SymfonyHttpExceptionsubclasses rendered through the application's own exception handler, with real copy inlang/en/messages.php.
lock mode
lockmiddleware flag: a transaction on the target record's own connection, aSELECT … FOR UPDATEre-read, and the precondition evaluated a second time inside the lock — which is what closes the check-then-write raceIf-Matchalone leaves open. Opt-in, per route.lock_timeoutconfiguration key, applied per request on PostgreSQL (SET LOCAL lock_timeout) and MySQL / MariaDB (SET SESSION innodb_lock_wait_timeout, restored afterwards), answering an expired wait with503 Service Unavailableand aRetry-Afterrather than an opaque500.LockTimeoutException, catchable as Symfony'sServiceUnavailableHttpException.composer test:lockand alockingCI workflow, exercising real row-lock contention against MySQL and PostgreSQL.
Configuration
- Nine keys:
enabled,strategy,hash,weak,last_modified,max_response_bytes,methods,exclude, andlock_timeout.methodsgoverns the read path only;excludesuppresses both paths and matches route names and URI patterns alike. - Publishable config, translations, and an assets tag, under
laravel-conditional-requestsand its three per-resource variants.
Failing loudly on a misconfiguration
- A
LogicExceptionnaming the offending route or config key whenweak => truewould invert a write guard, when a strategy cannot produce a validator before the controller runs on arequiredroute, whenlocknames a strategy that cannot identify a row or a resource that is not one, and when a write route binds more than one record implementingProvidesConditionalValidator.
Notes
lockis opt-in per route and changes nothing about a route that does not carry it.- A controller run under
lockis inside a transaction: a job it dispatches runs before the commit unlessafterCommitis set, and returning an error response commits rather than rolls back. - SQLite has no row locks —
lockForUpdate()compiles to nothing there — solockon SQLite gets the re-read and the re-evaluation without the exclusion. SQL Server locks and re-evaluates correctly but is not sent a lock timeout, so its own default of wait-forever stands whateverlock_timeoutsays. - A pre-controller
304answers before anything declared afterconditional, including per-record authorization. That, and seventeen other things worth knowing before deploying, are indocs/hazards.md.
Verified for this release
Measured on a clean Laravel 13.29 application that installed the package the way
a stranger would, on PHP 8.5, against MySQL 8.4.11 and PostgreSQL 16.15.
- Row locks against real databases.
composer test:lock— 5 passed, 0
skipped, on MySQL and again on PostgreSQL. The suite fails rather than skips
when the driver is absent, so a pass is proof it executed. - Concurrency, with the lock. 240 concurrent
PATCHrequests against a
conditional:required,lockroute through nginx + php-fpm on MySQL. Every
2xxwas a real serialised increment — the row's final version rose by
exactly the number of2xxresponses, every other request was a412, and
there were no lock timeouts and no unexpected statuses. - Concurrency, without it. The same route, the same load, with a deliberate
250 ms window opened between the guard's read and the controller's write:
withoutlock, 23 requests were answered2xxwhile the version advanced by
only 6 — 17 committed writes lost, every one of them past anIf-Matchthe
guard had accepted. Withlockand the identical window, 62xx, 24412,
and the version advanced by exactly 6. That gap is whatlockis for, and it
is whyIf-Matchalone is documented as check-then-write. - The lock timeout does not leak. Under Octane (FrankenPHP, one worker, one
persistent MySQL connection held across every request),
@@session.innodb_lock_wait_timeoutread 50 before a guarded write, 5 inside
the guarded transaction, and 50 again afterwards — and 50 again after a write
refused with412. The session is left as it was found. - The read path under three SAPIs. A 49-case matrix — cold
ETag,304
with a zero-length body and the tag echoed, controller-execution counts,
HEADparity, the*andW/*refusals behind a gate declared after
conditional,428,412on ...