Changelog
Release history for the quioteframework/quiote framework package. Entries marked breaking need a code or config change on upgrade.
The raw, per-commit changelog lives in CHANGELOG.md in the repository; this page is the curated version.
Propulsion versions independently.
Two extractions and one new subsystem. See Upgrading to 4.2 — there is exactly one thing to do, and only if you use the filesystem or object-store classes.
- breaking —
Quiote\Filesystem\*andQuiote\Storage\*moved out of the framework intoquioteframework/filesystemandquioteframework/storage. Every namespace is unchanged, so there is no code to edit, but the framework has norequireon either package:composer require quioteframework/filesystemis the whole migration. It fails quietly if you miss it — apluginsentry for a class that no longer exists is logged and skipped, not fatal. Upgrading to 4.2 - breaking (plugin authors) —
PluginManager::reset()no longer clearsFilesystemDriverRegistryby name. A plugin owning a static registry contributes its own clear with$registrar->stateReset('label', …). Plugins - New
quioteframework/replayand seven companion packages: record a real request as a cassette, replay it — in isolation, against stubs built from its own recorded effects — and emit it as a committed PHPUnit regression test. Recording is off unless configured, and live replay needs bothreplay.allow_liveand--live. All eight are tagged4.0.0-RC1. - The three
cloud-*packages no longer depend on the framework, so they are usable from a plain PHP project. Their shared contracts live inquioteframework/storage, whose only dependency ispsr/http-message. - Object listing is now a cross-provider operation:
listObjects()on everycloud-*client, normalized into one paginatedObjectListingdespite S3, GCS and Azure each shaping continuation differently — and the S3, GCS and Azure filesystem disks can list, where they previously refused at resolution. File storage quioteframework/cloud-azureauthenticates with Azure AD as well as a shared key:filesystem.disks.azure.auth(and the equivalent for the session and replay stores) takesshared_key,workload_identity,cliorchain. Tagged4.1.0-RC1, because that credential path has not yet run against real Azure. File storage: Azure Blob- New
Quiote\Support\Clock,Quiote\Support\RandomandQuiote\Support\Environmentseams, with every ambientnow(),random_bytes()/random_int()andgetenv()read inside the framework converted onto them — so a test can freeze time or seed randomness process-wide. Testing - A config value can reference the process environment with
%env(NAME)%or%env(NAME, fallback)%, resolved when the compiled artifact is loaded rather than when it’s compiled — so a cache baked into a container image carries the placeholder, not the build machine’s environment. A plugin’senabledaccepts the same placeholder, letting a deployment toggle a plugin with a variable and a restart. Configuration - Fixed —
SystemEnvironmentReader(and soQuiote\Support\Environment, and the framework’s ownQUIOTE_ENV/QUIOTE_APP_DIR/QUIOTE_CONTEXT/QUIOTE_WORKER_RUNTIME/QUIOTE_MAX_REQUESTS/QUIOTE_APCU_PREWARMreads) falls back to$_ENVwhengetenv()reports a variable unset. A dotenv bootstrap usingcreateImmutable()populates$_ENVwithout callingputenv(), so those variables used to read back as missing everywhere in the framework that wasn’t already going through the environment seam. - The editor diagnostics that flag a view an action reaches but doesn’t declare now cover a
return 'ViewName';inside anexecute*()method, not just agetDefaultViewName()override — which is the view almost every action actually reaches. - Fixed —
ObjectMetadatareads an ETag whatever quoting or weak-validator form the provider sends it in, and aContent-LengthpastPHP_INT_MAXis reported as unknown rather than silently wrapping. - Fixed — A slot’s error/success view gets the live
ValidationManagerrather than a fresh, empty one, so a slot displaying validation errors actually shows them.getValidationManager()is natively typed?ValidationManager(was?object) across the four init-context types, andViewInitContextdeclares thegetValidationManager()/getModuleName()its only implementation already had — so code typed against the interface no longer hits an undefined method. - Fixed — Plugin state cleanup gaps found auditing
packages/*: several packages kept static registries that survivedPluginManager::reset().
- Packages version independently of the framework from 4.0.0 onward.
quioteframework/db-doctrineat4.1.0and the framework at4.2.0are unrelated numbers; each package’s own changelog is the one to read. TheAt a glancetable on Official packages stays the map of what exists. - A plugin can register a production-facing exception renderer, not just a developer one:
$registrar->safeExceptionRenderer(…)fills the slotErrorHandlingMiddlewareuses whencore.developer_exceptionsis false, mirroring the existingdeveloperExceptionRenderer()seam. First registration wins; nothing registered falls back toSafeRenderer. Error handling - breaking, fixed —
StringValidator,JsonValidator,NumberValidatorandBooleanValidatorall write their cast/decoded value back under their own argument name when noexporttarget is configured (the last two already did, through duplicated code now shared asValidator::exportOwnArgumentByDefault()). Reading the parameter after aStringValidator/JsonValidatorused to hand back the raw input, so anintcould survive a string validator and blow up a strictly-typed setter downstream.exportstill redirects the value elsewhere; multi-argument and array validators are unaffected, andDateTimeValidatorstays opt-in deliberately. Validation - breaking —
Quiote\View\TemplateLayer’s__call()magic accessors are replaced withgetName()/setName(),getModule()/setModule(), andgetTemplate()/setTemplate()/hasTemplate()/removeTemplate(). A non-string value throws instead of being returned uncast. Any otherget*/set*name that used to be decomposed at runtime no longer exists. quioteframework/db-propulsionaccepts Propulsion 3.x alongside 2.x.
Decomposing Context into the collaborators it was standing in for, and deleting the accessors that stood in for them. The config cache must also be cleared once on the way in. See Upgrading to 4.0.
- breaking — Every config cache key now includes a framework fingerprint, so a framework upgrade recompiles automatically instead of reusing a cache compiled by an older version. Clear the cache once on the way in; from here it’s automatic. Configuration
- breaking — Compiled
factories,databases,output_typesandtranslationfiles return data, not executable PHPincluded into the object that reads them. Source formats are untouched. The per-component*FactoryInfoproperties onContextare gone. - breaking — Every remaining config handler (
settings,module,plugins,middleware,validators) compiles to a declaration too, removing the lasteval()s from the configuration cache. Breaking only for a hand-written config handler:execute()/executeArray()return the declaration (mixed) rather than generated PHP,BaseConfigHandler::generate()is gone, and a handler applied viaConfigCache::load()must implement the newQuiote\Config\IDeclarationConfigHandler. Configuration - breaking — Every
Contextaccessor that answered “some other service” is deleted:getRouting(),getController(),getRequest()/setRequest(),getUser(),getService(),getModel(),getDatabaseManager(),getTranslationManager(), the session pair, the execution helpers,createInstanceFor()andhandle().ContextInterfacedeclares two methods where 3.2 declared seventeen. Inject the collaborator instead —Quiote\Rector\Set\ContextDecompositionSetListrewrites the common shapes. Upgrading to 4.0 - breaking —
Context::handle()is gone; the per-request work lives inQuiote\Runtime\ContextRequestHandler, a real PSR-15 handler reached withgetRequestHandler().Context::$psrKerneland$correlationIdare gone too; usegetRequestHandler()->pipeline()/forgetPipeline()andgetCorrelationId(). ModelLocator,ContextRegistry,RequestStateandCurrentUserare separate, injectable classes. Container- breaking, fixed — An omitted
$scopeonContainer::set(),setFactory()andPluginRegistrar::service()no longer means process lifetime. The argument is nullable, and omitting it asks the binding: a class name keeps the lifetime its own#[Service]declares, a factory is request-scoped, an instance or a bound value is a singleton. Registering a class purely to alias it no longer changes what it is. Container - breaking —
ValidationService::xmlOnlyValidate()isvalidateDeclaredOnly(). Same signature, same behaviour: validators haven’t been XML-only for some time, and what the method skips is the action’s ownvalidate()methods, not a declaration format. Upgrading to 4.0 - breaking — Four deprecated validation methods are gone:
ValidationError::setMessageIndex()/getMessageIndex()(usesetName()/getName()) andValidationIncident::hasFieldError()/getFieldErrors()(usegetArguments()/getErrors()).ValidationManager::getFieldErrors()is a different method and is unaffected. Upgrading to 4.0 - breaking —
Quiote\Execution\ViewResolver(a deprecated stub forwarding toViewNameResolver) andActionExecutionSession(a transitional wrapper never wired into dispatch) are removed. - breaking —
QuioteException’s four exception-page helpers —getFixedTrace(),buildParamList(),highlightFile(),highlightString()— are removed from it and from every exception extending it. Rendering an exception isExceptionRenderer’s job. Upgrading to 4.0 - Fixed —
ViewTestCase’s response assertions compare what they document: a redirect target against the location, a header against one of its values, a cookie against its value.runView()hands the view the request rather than the request’s parameter array, which was aTypeErroragainst any view. Testing - Fixed — A category logger is re-resolved when the logging configuration changes, instead of serving a logger built against the old one.
- Fixed —
Toolkit::overloadHelper()returns the matching method, andDatabase::reset()honours the teardown contract. Thedb-propulsionadapter no longer discards live connections on everyinitialize(). - Fixed —
PropulsionDatabase::getConnection()/getResource()re-resolve from Propulsion on every call instead of trusting a cached handle, so a reconfiguration on another instance can no longer leave this one silently pointed at a dropped connection.EloquentDatabase::getCapsule()follows the same fix through layer mode: it now re-checks the source database’s current PDO on every access and rebinds it into the Illuminate connection when it has rotated underneath.PropulsionDatabase·EloquentDatabase Quiote\Renderer\PhpRendererexposes the attributes array under the configuredvar_name(templateby default), by reference and as the array itself — so reading a key the action never set is an undefined-key warning rather than silence. Templates and rendering- New
core.stealth_modestrips framework-identifying headers from every response — anyX-Quiote-*header, plus the names incore.stealth_additional_headers(X-Powered-Byby default).StealthMiddlewareruns outside the error handler, so error and 404 responses are covered too. Middleware reference - A generated API reference — every public class, interface, trait and enum the framework ships, with its methods and their types, built from the source rather than maintained by hand.
- Middleware holding per-request state can implement
Symfony\Contracts\Service\ResetInterface; the context callsreset()on every middleware in the built stack at the end of each request. The stack itself is kept, and areset()that throws is logged rather than silently skipping the rest. Custom middleware - Fixed — A declared
sessionfactory was ignored:FactoryConfigHandleranswered “is this slot optional?” and “is it switched off?” with one flag, so the optional session slot was never read and every app declaring one silently got aNullSessionBag. Declarations are now read regardless, and asessionfactory naming a class that doesn’t implementSessionFactoryInterfaceis rejected at compile time instead of ignored. The reverse case is fixed too: an optional slot whose subsystem is switched off (atranslation_managerwithcore.use_translationfalse) is no longer built anyway. Sessions - Fixed — A bearer-authenticated identity that then logged in through the session kept its session id; the id is rotated on that transition like any other privilege change.
- Fixed — A Postgres session blob is read as a stream rather than a string, so
byteasessions load. - Fixed — Slot parameters are restored from the validated request, and
RoutingValue::reset()no longer unsets a shared static property. - The
_original_psr_requestattribute is gone. It carried a copy of the request as the client sent it — unvalidated input under a well-known name, readable from any middleware, action or view — past the pruned canonical request that strict validation produces. Nothing in the framework read it. - New
Quiote\ContextLifecycleowns the per-request state machine, andPluginManager::addRequestEndClear()lets a plugin clear its own request-scoped state at the boundary. Plugins - Validators can declare constructor dependencies; construction goes through the container. Custom validators
- Fixed — Injecting
WebRequest,User,ISecurityUser,Routing,TranslationManagerorDatabaseManagerby base class autowired a fresh, empty instance instead of the request’s real one. The base classes are bound alongside the concrete class now; the same wiring in a singleton throws at wiring time rather than leaking one request’s identity into the next. - Fixed — A throwable during
Context::reset()could abort the reset before identity was cleared, handing the next request in a worker the previous request’s authenticated user. Identity is now cleared first and unconditionally. - The execution helpers (
getActionResolver(),getAssetRegistry(),getSlotDispatcher()) resolve through the container with declared lifetimes, and are injectable. - breaking, fixed — An unregistered, autowired class defaulted to singleton scope — the container’s most dangerous default. It now defaults to request scope; opt into process lifetime explicitly. This is what a singleton constructor-injecting
RbacSecurityUserorWebRequestwas silently doing. Container - breaking, fixed — A bare
#[Service](noscope:argument) defaulted to singleton, disagreeing withServiceInterface’s transient default — so adding the attribute to an existing service for discoverability silently promoted its lifetime. Both now default to transient. Services and models
Tightening contracts that were quietly wrong. Most applications need no changes; see Upgrading to 3.2 for the three worth grepping for.
- breaking —
WebResponse::setHttpStatusCode()accepts any code in 100–599. The per-protocol whitelist made 422, 429, 308, 451, 507 and 511 unsettable, and fell through to the HTTP/1.0 list on HTTP/3. Requests and responses - breaking —
PsrResponseAdapteris immutable:with*()clones instead of mutating the sharedWebResponseand returning$this. A discarded return value is now a no-op rather than a hidden mutation. Requests and responses - breaking —
Config::$configis private;Quiote\Config\ConfigRepositoryholds the behaviour and is injectable. The whole staticConfigAPI is unchanged. Configuration - breaking —
ValidationMiddlewarerequires aController; it no longer resolves one from the'web'context by name. - breaking —
listContents()moved offFilesystemAdapterInterfacetoListableFilesystemInterface; three of the four shipped drivers never could honour it. File storage - breaking — One
Quiote\Storage\ObjectMetadataand oneObjectStoreClientInterfacefor every object store; the three per-provider metadata classes are gone. Provider exceptions now extendObjectStoreException. - breaking —
cors.allowed_origins: ['*']withcors.allow_credentials: truethrows at boot instead of emitting a pair browsers reject.quioteframework/cors - One
Quiote\Session\SessionCodecbehind every session backend; seven implementations disagreed on how to read back what they wrote. Sessions WebRequest’s seven URL setters now also rewrite the wrapped PSR-7 URI, and are deprecated in favour ofwith*()counterparts. Requests and responses- Fixed — A view’s
setAttribute()was invisible togetAttribute(), andappendAttribute()did nothing under the modern execution path. The two attribute stores are one. - New contracts:
ContextInterface,ControllerInterface,WebResponseInterface,ValidatorInterface,ContextComponentInterface.TelemetryBootstrapis decomposed with its API unchanged. - Failures on the dispatch path — dropped status, headers, redirects, cookies — are logged instead of vanishing.
A security release. Every entry closes a gap that was silently ineffective rather than loudly broken, so there’s nothing to change in application code — but several change what your app actually enforces.
- CSRF validation now runs.
CsrfValidationMiddlewaredecided “no session cookie” by looking forsession_name()—PHPSESSID— whileSessionManagernames its cookieQSIDand doesn’t use ext/session at all. The probe never matched, so every unsafe request looked sessionless and was exempted, in every app using the framework’s own session manager. Forms still received a token, so the failure was invisible. Authentication & authorization - breaking — The CSRF exemption for a request carrying an
Authorizationheader is gone. Header presence proves nothing:Authorization: Bearer <garbage>plus a valid session cookie authenticated via the cookie and skipped the token check. The exemption now requiresauth.stateless/auth.sessionless/jwt.skip_session, set only after an authenticator validated a caller-supplied credential. - A privilege transition deletes the old session id outright.
regenerate()only deleted it when the session happened to be empty, and a real login session always holds something — the CSRF token at minimum — so login always took the tombstone path, leaving an id an attacker had planted rideable for the whole grace window. The window keeps doing its real job on routine rotations. Sessions - Security fails closed when an action can’t be evaluated. When
createActionInstance()orinitialize()threw,SecurityMiddlewaregranted access on “is authenticated” alone, skipping the action’s ownisSecure()/getCredentials()requirements. Authenticated is not authorized. - A failed context reset can no longer leak the previous user into the next request a worker serves.
- The scaffold generates a
sessionslot, so a new app actually enforces CSRF, anduserisRbacSecurityUser. Your first app - Firewall patterns are validated at construction. An unanchored pattern matched anywhere in the path (
/adminalso covered/public/admin-notes), and an invalid one madepreg_match()return false — read as “no match”, so a regex typo left every path it guarded unauthenticated.matches()also tests a canonicalized path, so/api/%2e%2e/adminno longer depends on what the proxy in front normalized. The firewall model - Login throttling is per client, and the identifier probe is constant-cost.
||short-circuiting meant an unknown identifier returned after one indexed SELECT while a known one paid a full argon2id verification — a reliable enumeration oracle. The throttle keyed on the identifier alone, which did nothing about horizontal credential stuffing and handed an attacker a lockout primitive against a known victim. Login rate limiting - The framework-middleware override guard covers the CSRF middleware, and an unresolvable
before:/after:reference declared by a guarded middleware now throws instead of dropping the constraint. A single<use>entry could previously disable CSRF validation or reorder it past dispatch. Middleware pipeline - breaking —
RateLimitMiddlewarereads the trusted end ofX-Forwarded-For, skippingratelimit.http.trusted_proxy_hopsentries (default 1). A proxy appends rather than replaces, so keying on the leftmost value let a caller rotate the key per request and buy no throttling at all. - CORS no longer emits a wildcard origin alongside credentials — a pair the fetch spec forbids, so browsers rejected the response while non-browser clients honoured it. (3.2 turns this into a boot-time configuration error.)
- A queued job’s class is verified before it is constructed, so a queue row an attacker can influence can’t have an arbitrary autoloadable class built with chosen constructor arguments. Queues
- An MCP tool call that was forwarded fails instead of handing the connected model the login page’s markup as the action’s output. MCP server
Authorizationscheme parsing follows RFC 9110 — case-insensitive, any run of whitespace — in both the Basic and Bearer authenticators, and a bareBasic/Beareris claimed and answered with a challenge rather than falling through as “nothing presented”.- A failed validation decision reaching dispatch is negotiated: Problem Details for a JSON client, the HTML fragment otherwise, instead of a hardcoded
<div>Validation Failed</div>for everyone. Validation
make:actiontemplates are generated from the configured renderer, so a scaffolded action matches the app’s own template language.- Four latent defects repaired in response headers, cache keys, OAuth scopes and rate limiting.
Object metadata for the cloud file storage disks.
S3Client,GcsClientandAzureBlobClientgained ahead()operation, returning a typedObjectMetadata/BlobMetadata(content length, last-modified, ETag).size()andlastModified()now work on thes3,gcsandazurefilesystem disks; they previously threw unconditionally.exists()on a cloud disk issues a HEAD rather than a GET, so it no longer transfers the object body just to answer a boolean.- All three clients expose
request(), which signs an arbitrary request and returns the raw PSR-7 response, so a bucket listing —ListObjectsV2,List Blobs, pagination included — could be built without reimplementing SigV4, HMAC or Shared-Key signing. (4.2 made listing a first-class operation on all three clients, sorequest()is now only for what the typed methods don’t cover.) See Reaching past the contract. listContents()still throws on all three cloud disks: there is no list operation behind the typed client surface.
The session overhaul. Sessions became PSR-7-native and the ext/session-backed storage component was removed.
Upgrading from 2.x? Read Upgrading from 2.x to 3.0 — this release is not drop-in.
Sessions
Section titled “Sessions”- breaking — The
storagefactory slot and theQuiote\Storage\*stack (Storage,SessionStorage,NullStorage,PdoSessionStorage) are removed. Sessions are configured through the new, optionalsessionslot. SessionBagInterfaceis the single seam every session consumer talks to — theUserhierarchy, CSRF token storage, OIDC state, and application code — reached viaContext::getSessionBag(). An unconfigured context answers aNullSessionBag.- Every backend ships a
sessionslot factory, so switching backend is a class name in config with nothing to wire by hand: files and PDO in core, plus Redis, S3, GCS, Azure Blob and Azure Table in their packages. - breaking — Anonymous requests no longer create a session or emit a cookie. A request that writes nothing costs nothing.
- breaking —
setAuthenticated(false)discards the session contents and rotates the id, so a logged-out id is neither replayable nor inheritable. - breaking — Request state is persisted before the response is emitted, inside
SessionMiddleware. Code mutating the user after the pipeline unwind no longer persists. - breaking — Only session state that actually changed is written;
Usersubclasses writing to$attributes,$credentialsor$rolesdirectly must callmarkDirty(). - breaking —
SessionManager::regenerate()andmigrateOld()take an additional optional request argument, used to bind the migration tombstone to the requesting client. - Session identity is proven to survive across worker requests, with FrankenPHP now covered in the worker integration suite alongside RoadRunner and Swoole.
Packages
Section titled “Packages”- breaking — The signed cloud REST clients moved into three new packages:
cloud-s3,cloud-gcsandcloud-azure. The matchingsession-*andfilesystem-*packages now both depend on them, rather thanfilesystem-s3depending onsession-s3to obtain a client. They are transitive dependencies — nobody installs them directly.
- The read cursor is released in the PDO session backends, and the PDO upsert is portable across MySQL, SQLite and Postgres.
- The native session lifecycle is repaired under worker runtimes.
Quiote\Middleware\SessionMiddlewareresolves session ids through the bag.
- The scheduler rebinds the default schedule per test, stopping cross-test leakage.
- Redundant
Before/Afterattributes dropped from the HTTP client’ssetUp/tearDown.
A broad feature release: worker runtimes, queues, scheduling, and a large performance pass.
Features
Section titled “Features”- Worker runtimes — RoadRunner and Swoole runtimes, verified against real servers.
- breaking — The worker adapter was replaced with a runtime-agnostic contract;
WorkerAdapterInterface,FrankenPhpWorkerAdapterandSingleRequestAdapterare gone. - Background jobs and queues — the
queueabstraction with asyncdriver,queue:work, plusqueue-dbandqueue-redisdrivers. - Scheduled tasks — cron-expression scheduling and
schedule:run. - Server-Sent Events streaming.
- OpenAPI 3.1 documents derived from routes and validators.
#[MapRequest]attribute-based request-DTO mapping.- CORS, security-headers and HTTP rate-limit middleware.
- Redis backends for cache, queue, session and rate-limit storage.
- A general-purpose file storage abstraction with a local disk plus S3, GCS and Azure packages, and a file-backed session persistence backend.
- A fluent HTTP test client.
make:*generators and aservecommand.- OIDC discovery for
auth-oauthprovider metadata. - Renderers can author their own scaffold starter template.
- breaking — Legacy 0.11/1.0 config envelope migration was dropped.
Performance
Section titled “Performance”A framework-wide audit: OPcache preloading of core classes for FrankenPHP workers, a compiled routing IR artifact that skips the live scan, cached ICU formatters and gettext catalogs, memoized config-format resolution, cached validation/model/session/RBAC/logging/event/translation/template hot paths, and a core.config_check_freshness production trust-cache mode.
- Routing and translation-manager state is reset between worker requests.
- The session redirect grace window and slot cache TTL are guarded against backward wall-clock steps.
- Gettext plural forms selected the wrong
msgstr. - Dead XML routing config path removed.
Packaging and release-tooling fixes: per-output-type template resolution in app introspection, and several composer.json corrections for the split packages.
The first stable release.
- Declarative
plugins.xml/middleware.xmlconfig with attribute-gated plugin activation. - The authentication foundation — form login, HTTP Basic, JWT and OIDC.
- Array-shape schema validation and position tracking for all config types.
- A compiled route/module/triad introspection artifact for the VS Code extension.
- Plain-class MCP attribute discovery with a discovery-cache warmup.
getPdo()on every database adapter, for raw SQL access.- PHPStan raised to level 8 across framework and test suite.
- breaking — Strict-mode bypasses closed in
getParameters(),isSimple()and headers. - breaking — Custom middleware placement defaults to after
ValidationMiddleware. - breaking —
WebRequestdecomposed into immutable collaborators. - breaking — Plugin names resolve from the
#[Plugin]attribute, notPluginInterface::name().