Changelog
All XPSR packages share one version number. See docs/VERSIONING.md for what each kind of release promises.
Unreleased
0.6.0 (2026-11-02)
Breaking
Crystoline\Xpsr\Contracts\Container\ContainerBuilderInterfacegainedextend(). A container of your own, passed toApplication, must implement it; the framework'sServiceContainerdoes. See docs/CONTAINER.md.
Added
#[AfterCommit]jobs incrystoline/xpsr-queue: dispatched inside a database transaction, the job is sent once the outermost transaction has committed, and dropped if it rolls back, or if the savepoint it was dispatched in does. Outside a transaction, it is sent at once; onsync://, it runs after the commit. See its README.Transactions::afterCommit()incrystoline/xpsr-databasedoes the same for any callback, on transactions DBAL or Eloquent opened. The connections ofConnectionManagerare nowCrystoline\Xpsr\Database\Connection, a DBAL connection that reports its transactions; a configuredwrapperClassmust extend it. See its README.- A job's
failed()method is called after its last attempt, with the exception first and services after, as in Laravel, onsync://too. An exception fromfailed()is logged.JobRunner::fail()does the calling. #[Retry]on a job sets its retries in place of its transport's:maxRetries,delay,multiplier, andmaxDelay, each kept from the transport when left out. A mistake in it is reported when the job is dispatched.- Query events in
crystoline/xpsr-database: with the event dispatcher, each query dispatchesCrystoline\Xpsr\Database\Event\QueryExecuted(connection, SQL, parameters, seconds), whether DBAL or Eloquent ran it, anddatabase.slow_querieslogs queries over a duration as warnings. See its README. - Eloquent's connections in
crystoline/xpsr-orm-eloquenthave an event dispatcher (Illuminate\Events\Dispatcher, a service), solisten(),whenQueryingForLongerThan(), and Eloquent's query and transaction events work. Before, they did nothing, without an error. everySeconds()incrystoline/xpsr-scheduler: a task repeats every so many seconds within each minute its schedule matches, andschedule:runstays alive until that minute ends.schedule:listshows the period. See its README.Crystoline\Xpsr\Testing\TestResponse: requests in tests come back with assertions that chain:assertStatus(),assertOk(),assertCreated(),assertNoContent(),assertNotFound(),assertUnauthorized(),assertForbidden(),assertUnprocessable(),assertRedirect(),assertHeader(),assertHeaderMissing(),assertJson(),assertExactJson(),assertJsonPath(),assertJsonMissingPath(),assertJsonCount(),assertJsonValidationErrors(),assertSee(), andassertDontSee(), withjson(),body(), andstatus(). It is the PSR-7 response as well, sogetStatusCode()andgetBody()work as before. A failed assertion shows the status and the start of the body. See docs/TESTING.md.Crystoline\Xpsr\Testing\ApplicationTestCase, the base class of an application's tests: what the skeleton'stests/TestCase.phpused to hold, so thattests/TestCase.phpis three lines and new helpers reach existing applications. New in it:getJson(),putJson(),patchJson(),deleteJson(),json(),withHeaders(), andwithToken().- Test traits in
crystoline/xpsr-database:DatabaseAssertions(assertDatabaseHas(),assertDatabaseMissing(),assertDatabaseCount(),assertDatabaseEmpty()),RefreshDatabase(drops and migrates the database before the first test of the process, then rolls each test back; migrates every time on an in-memory SQLite database), andDatabaseTruncation(commits, and empties the tables after each test, for data that other connections or processes must see). See its README. - Assertions on the fakes:
FakeQueue::assertDispatched(),assertNotDispatched(), andassertNothingDispatched();FakeNotifier::assertSent(),assertNotSent(), andassertNothingSent();FakeClient::assertRequested(),assertNotRequested(), andassertNothingRequested(). A failed assertion lists what was dispatched, sent, or requested. - docs/TESTING.md: everything an application's tests have.
crystoline/xpsr-api: JSON responses for APIs. ACrystoline\Xpsr\Api\Resourceshapes an object into JSON field by field, withwhen(),whenNotNull(),mergeWhen(), andwhenLoaded()(Eloquent's loaded relations, and Doctrine's initialized lazy objects and collections), nested resources and collections, and adataenvelope (api.wrap); a route handler returns it, withwithStatus(),withHeaders(), andwithMeta().Resource::collection()andResourceCollectionrender lists.Pagination::fromRequest($request)->paginate($query)readspageandper_pageand pages a DBAL query builder, a Doctrine query (counting entities, not joined rows), an Eloquent builder or relation, or an array, with Laravel'sdata,links, andmeta;simplePaginate()skips the count.make:resourcewrites a resource class. See its README.Crystoline\Xpsr\Http\ResponsableInterface: a route handler may return an object withtoResponse($request, $container)in place of a PSR-7 response; the handler resolver converts it. Resources, collections, and pages implement it.crystoline/xpsr-installer, installed globally:xpsr new my-appcreates a project withcomposer create-projectand the project's ownsetupin one step, asking for the router and preset or taking--router,--preset,--git,--dev,--release,--repository, and--composer. Inside a project, any otherxpsrcommand runs the project's ownxpsr, sophp xpsr servebecomesxpsr serve. See its README.crystoline/xpsr-webhooks: webhooks.Endpointskeeps subscribers (URL, events such aspayment.*, a secret stored encrypted withAPP_KEY), refusing URLs that resolve to private or local addresses.WebhooksInterface::send($event, $data)records a delivery per subscribed endpoint and queues a signed JSON POST (X-Webhook-Id,-Event,-Timestamp, and-Signature: t=...,v1=<HMAC-SHA256>) that leaves after the open transaction commits; 2xx delivers, 410 disables the endpoint, anything else is retried five times over about six hours, then recorded as failed;WebhookDeliveredandWebhookFailedevents;Deliveriesis the log,webhooks:resendandwebhooks:prunemanage it, andmake:webhooks-tablewrites the migration.VerifyWebhook::named('stripe')middleware verifies providers' webhooks with thestripe,xpsr,hmac, andtokenschemes ofwebhooks.inbound.Testing\FakeWebhookshasassertSent(). See its README.crystoline/xpsr-audit: an audit log.AuditLog::record($action, $subject, changes: [...], context: [...])writes who did what to which record, with the actor from the signed-in identity, the client IP and user agent fromAuditContextMiddleware, the request ID from the Monolog package, andfield => [before, after]changes, redactingaudit.redactfields. Eloquent models with theAuditedtrait and Doctrine entities with#[Audited]are recorded automatically on creation, update, and deletion, inside the transaction.for(),by(),recent(),search(), andquery()read the log;make:audit-tableandaudit:prunemanage it;Testing\AuditAssertionsaddsassertAudited(). See its README.orm.listenersincrystoline/xpsr-orm-doctrine, andCrystoline\Xpsr\Orm\Doctrine\DoctrineListenersfor other providers:Doctrine\Common\EventSubscriberservices the entity manager gets when it is built.crystoline/xpsr-telemetry: distributed tracing on the OpenTelemetry SDK, off untilOTEL_ENABLED=true. A server span per request named by its route, a client span per database query and per outgoing request of the HTTP client package (which carriestraceparentto the called service), producer and consumer spans for queued jobs in the trace that queued them, and a span per console command and scheduled task; failures mark their spans and record the exception.Tracer::span()for the application's own spans;trace_idandspan_idon every Monolog line; OTLP over HTTP to any collector, in batches at shutdown, with sampling;Testing\FakeTelemetrywithassertSpan(). See its README.extend()on the container: wraps a service with a closure that gets the service as defined so far and returns its replacement, keeping the lifetime. See docs/CONTAINER.md.- Framework events, with the event dispatcher:
Crystoline\Xpsr\Routing\Event\RouteMatchedonce a request matched a route;Crystoline\Xpsr\Console\Event\CommandStartingandCommandFinishedaround each console command, with the exit code or the exception;Crystoline\Xpsr\Scheduler\Event\TaskStartingandTaskFinishedaround each scheduled task that runs, with its result. See docs/EVENTS.md. Crystoline\Xpsr\Queue\BusMiddleware: Messenger middleware other providers add to the queue's bus, run first on dispatch and on handling.logging.processorsincrystoline/xpsr-log-monolog, andCrystoline\Xpsr\Log\Monolog\LogProcessorsfor other providers: Monolog processors the logger gets when it is built.
Changed
- The Eloquent provider clears Eloquent's booted models when it boots, so models register their event listeners (
booted(),boot<Trait>()) again on the new dispatcher when another application boots in the same process, as in tests. Before, a model first used under one application kept its listeners on that application's dispatcher, and lost them under the next. database.log_querieslogs Eloquent's queries too, and each query's parameters and time, in one format ("Query on main took 0.21 ms: SELECT ...") in place of DBAL's. Before, only the queries DBAL ran were logged.DatabaseTransactionsdoes not count the test's own transaction forafterCommit(): callbacks and#[AfterCommit]jobs run when the code under test commits its outermost transaction, inside the test's.schedule:runhandles SIGTERM and SIGINT: the repeats ofeverySeconds()tasks stop after the current task.- The Eloquent adapter asks DBAL for the database name once per connection instead of twice.
- The framework conflicts with
sebastian/recursion-contextbelow 7.0.1: older versions callSplObjectStorage::contains(), deprecated in PHP 8.5, when PHPUnit reports a failed assertion on objects. tests/TestCase.phpin new projects extendsApplicationTestCase; the skeleton's example test uses the response assertions. An existing project gets them by making itstests/TestCase.phpdo the same; see UPGRADE.md.actingAs()returns the test case, so it chains.
Fixed
- The Eloquent adapter kept counting a transaction as open when its commit threw. It now counts it down first, as Laravel does.
- The UUIDs of
crystoline/xpsr-webhooksandcrystoline/xpsr-auditcount up within a millisecond, so two rows created together sort in the order they were created. Before, their order within one millisecond was random, which showed as the newest audit entry or the first endpoint sometimes coming second.
0.5.0 (2026-10-30)
Nothing breaks if the web server can write var/locks, which rate limits now use. Upgrade steps are in UPGRADE.md.
Added
crystoline/xpsr-lock: locks across processes and servers, on Symfony Lock.Locks::run()takes a lock, runs a callback, and releases the lock, also when the callback throws. It can wait a given time for another holder to finish.lock()returns the Symfony lock.- Stores:
flock(the default, files invar/locks), one Redis server, Redlock across three or more independent Redis servers (a lock needs more than half), the application's database (php xpsr make:lock-table), or any Symfony Lock DSN. - See its README.
ConnectionManager::open()incrystoline/xpsr-database: a new connection with a configured connection's settings, outside the shared connection's transactions.RateLimitsincrystoline/xpsr-cacheapplies the limits inconfig/rate_limit.phpto a key chosen in code, such as an email address or a user ID, where the middleware counts by client address.attempt()answers 429 over the limit,hit()returns what is left without throwing,remaining()checks without counting, andreset()forgets a key's attempts. See its README.- Encryption with
APP_KEY, in Laravel's format, so data a Laravel application encrypted with the same key decrypts in XPSR and the other way round. See docs/ENCRYPTION.md.Crystoline\Xpsr\Encryption\Encrypter, a core service withencryptString(),decryptString(),encrypt(), anddecrypt(). Ciphers: AES-256-CBC (the default), AES-128-CBC, AES-256-GCM, and AES-128-GCM.- Old keys in
APP_PREVIOUS_KEYS(app.previous_keys) still decrypt. decrypt()reads strings, numbers, booleans, null, and arrays, but refuses serialized objects, which Laravel'sdecrypt()restores. A leaked key therefore cannot be used to run code through the application's classes.php xpsr key:generateprints a key;--writesetsAPP_KEYin.env.- In
crystoline/xpsr-orm-eloquent, Eloquent's encrypted casts (encrypted,encrypted:array,encrypted:collection,encrypted:object,AsEncryptedArrayObject,AsEncryptedCollection) use it, so Laravel's encrypted columns read as they are.
- The
sanctumguard driver incrystoline/xpsr-databasereads and writes Laravel Sanctum'spersonal_access_tokenstable as it is. An application moving from Laravel keeps its clients' tokens, and while both run on the table, Laravel and XPSR accept each other's tokens. See docs/AUTH.md.- Sanctum's
<id>|<secret>tokens are accepted, and new ones have Sanctum's format, withauth.sanctum.prefixassanctum.token_prefix. - Abilities are the token's scopes. Each guard accepts only its own
tokenable_type, by default its provider's model class, so an admin's token never counts on the customers' API. expires_atandauth.sanctum.expirationend tokens as in Sanctum. Times are inapp.timezone, as Laravel stores them.SanctumTokenRepositorycreates, lists, and revokes tokens from code, andauth:tokenandauth:token-revokework on sanctum guards.auth:token-revokegains--guard.- Checked against Laravel Sanctum 4 itself: each accepts tokens the other created.
- Sanctum's
php xpsr migrate:baselineincrystoline/xpsr-databasestarts the migrations of an existing database, such as a Laravel application's. See its README.- It writes
migrations/00000000000000_baseline.php, which creates every table the database has, with columns, indexes, unique constraints, and foreign keys. The file uses DBAL's schema editors, so the tables of a MySQL database can also be created in SQLite for tests. - It compares the tables the file builds with the database's and prints any difference, then records the baseline as run without running it.
- On another database that already has the tables, it only records the baseline. Rolling the baseline back is refused.
--excludeleaves tables out, and Laravel's migrations table is left out on its own.- The migration commands stop with a message when
database.migrations.tablenames Laravel's migrations table, which has noexecuted_atcolumn. Before,migratefailed with a database error when it recorded a migration.
- It writes
#[Unique]jobs incrystoline/xpsr-queue: while a job is queued or running, dispatching an identical one drops the new one. See its README.- Jobs are identical when they are of the same class and their
uniqueId()methods return the same value. - The lock is released when the job succeeds or fails for the last time; with
until: 'processing', when it starts.forcaps how long it lasts, so a worker that dies cannot keep it. - It needs
crystoline/xpsr-lockon the Redis, Redlock, or database store, because the worker releases the lock that the dispatching process took. With theflockstore, dispatching a unique job throws an exception that says so. FakeQueuedrops duplicates too, and lists them indropped().queue:retrykeeps a failed unique job, and says so, while an identical job is queued or running.
- Jobs are identical when they are of the same class and their
- Queued jobs carry the request ID of
crystoline/xpsr-log-monolog. The worker logs with it while the job runs, so a request's log lines and those of the jobs it queued share onerequest_id. Locks::key()andLocks::fromKey()incrystoline/xpsr-locklet a process release a lock another process took, by sending the lock's key. See its README.crystoline/xpsr-notifications: notifications by email, SMS, an in-app inbox, and browser push. See its README.- A notification goes to the channels whose interfaces it implements (
MailNotificationInterface,SmsNotificationInterface,DatabaseNotificationInterface,WebPushNotificationInterface), or to those itschannels()method chooses for each recipient. Channels of your own implementChannelInterface. send()tries every channel, then throws one exception that lists the failures.queue()makes the messages at once and queues one job for each recipient and channel, so a channel that fails is retried alone.- SMS goes through Symfony Notifier's provider bridges, such as Termii, Twilio, or Vonage, chosen by a DSN, with failover to a second provider.
Inboxlists, counts, marks, and deletes a user's stored notifications. Every call is limited to that user's notifications.- Web push signs with VAPID keys (
php xpsr webpush:keys) and keeps browsers' subscriptions inPushSubscriptions. It accepts endpoints only on known push services, and deletes subscriptions that have expired. - The notifications and push subscriptions tables, the web push JSON, and the
VAPID_*keys are Laravel's, so an application that moves from Laravel keeps its users' notifications and subscribed browsers. FakeNotifiermakes the messages and keeps them instead of sending them, for tests.
- A notification goes to the channels whose interfaces it implements (
- docs/FROM_LARAVEL.md moves a Laravel application to XPSR: what carries over as it is, the steps, Laravel's APIs and their XPSR counterparts, and running both on one database during the move.
- SECURITY.md: how to report a vulnerability, and which versions get fixes.
php xpsr view:clearemptiesapp.view_cache_dir, for deploys that keep the files' original times. See docs/VIEWS.md.
Changed
- The scheduler's
withoutOverlapping()uses the application's locks whencrystoline/xpsr-lock's registry is listed andscheduler.lockis not set. A project that setsscheduler.lockkeeps its own store; the old README'sconfig/scheduler.phpset it toflock. - New projects keep compiled templates in
var/cache/views(app.view_cache_dir) instead of compiling them on every request. Through Apache with OPcache, the skeleton's home page went from 7.0 ms to 4.0 ms. Existing projects keep their setting. - Twig and Latte compile a changed template again even with
app.debugoff, as Blade already did. A deploy no longer serves old templates until the cache is cleared. Twig compares file times, which costs about 0.01 ms per page. Latte compares content, which costs about 0.2 ms for a page of three templates.
Fixed
- Parallel requests could get far past a rate limit, because counts were read and written back without a lock. In a test, four processes making 20 attempts each against a limit of 20 had 70 to 78 accepted; now exactly 20 are. Each count is now updated under a lock: the application's locks when
crystoline/xpsr-lock's registry is listed, otherwise files invar/locks. With several servers, use the lock package on a shared store. A Redis lock adds about 0.65 ms to each limited request. - Latte failed with "Unable to create directory" when the parent of
app.view_cache_dirdid not exist. The handler now creates the whole path, as the Blade handler does. - HTTP/1.0 requests are answered with HTTP/1.0. Every response used to say HTTP/1.1. Under Apache with mod_php, that kept the connection open until the keep-alive timeout, 5 seconds by default. HTTP/1.0 clients that read until the connection closes, such as some health checks and monitoring tools, waited that long for every response. Responses to HTTP/1.1 and HTTP/2 requests are unchanged.
0.4.0 (2026-10-29)
One change can affect existing code (see Breaking). Upgrade steps are in UPGRADE.md.
Breaking
- A missing view throws
Crystoline\Xpsr\View\ViewNotFoundExceptioninstead of Twig'sLoaderError. Code that catchesTwig\Error\LoaderErrorfor a missing template must catch the new exception instead.
Added
- View engines next to Twig, chosen with
app.view_engine(VIEW_ENGINE):crystoline/xpsr-view-plates(native PHP templates),crystoline/xpsr-view-latte(context-aware escaping), andcrystoline/xpsr-view-blade(Blade throughilluminate/view, with@csrfand the template functions on$xpsr), or aViewHandlerInterfaceclass of your own. See docs/VIEWS.md. Crystoline\Xpsr\View\ViewFunctionsInterface, through which the view provider gives every enginecsrf_token(),csrf_field(),session(),auth_user(), andcan();ViewNotFoundException; andCrystoline\Xpsr\Testing\ViewHandlerContractTestCase, the behavior every engine must have.crystoline/xpsr-storage: file storage on Flysystem 3. Named disks inconfig/storage.php, each aFilesystemOperatorfromStorageManager::disk(); driverslocal,memory, ands3(withleague/flysystem-async-aws-s3, for S3 and compatible services), and an application's own asstorage.driver.<name>.store()saves an upload under a random name with an extension from its content;download()streams a file as a response;fake()swaps a disk for memory in tests. See its README.crystoline/xpsr-scheduler: scheduled tasks from one cron entry.routes/schedule.phplists console commands, callbacks (services injected by type), and queued jobs with frequencies such asdaily('03:00'),weekly('monday', '08:00'),everyMinutes(15), orcron(), limited withweekdays(),weekends(),on(),when(), andskip(), inapp.timezoneor a task's owntimezone().withoutOverlapping()locks through Symfony Lock (files invar/locksby default, or a shared store).php xpsr schedule:run,schedule:list, andschedule:work. See its README.crystoline/xpsr-mail: email on Symfony Mailer, configured bymail.dsn(MAIL_DSN): SMTP, sendmail, provider bridges,log://(the default, written to the application log), andarray://for tests.Mailer::compose()renders a view as the HTML body with a text version;send()fills inmail.from;queue()sends from acrystoline/xpsr-queueworker.php xpsr mail:test. See its README.crystoline/xpsr-queue: queued jobs on Symfony Messenger. Jobs implementJobInterfaceand handle themselves (handle()gets services by type);QueueInterface::dispatch()with a delay or transport; transportssync://,in-memory://,database://, and Redis, AMQP, or SQS; retries with growing delays, then afailed_jobstable;php xpsr queue:work,queue:failed,queue:retry,queue:forget,queue:restart,make:job, andmake:queue-table;FakeQueuefor tests. See its README.TableMigrationCommandgainsstubPath()andreplacements(), for table migrations in other packages.crystoline/xpsr-orm-eloquent: Eloquent models on XPSR's database connections, sharing their transactions, withphp xpsr make:modeland theeloquentuser provider forconfig/auth.php. Schema changes stay XPSR migrations. See its README.StubGenerator::withStubDirectory(), formake:*commands in other packages.- Authentication in the framework (
Crystoline\Xpsr\Auth\):AuthManagerwithattempt(),verify(),login(),logout(), andintended(); guards configured inconfig/auth.php; the session guard for browsers;PasswordHasher(Argon2id or bcrypt, upgraded at sign-in);Identity::of($request); the middlewareAuthenticate(required, optional, or per guard),Guest, andRequireScopes; 401 and 403 errors withWWW-Authenticatechallenges. See docs/AUTH.md. - Several kinds of user: named providers in
auth.providers, aproviderand its ownlogin_pathandhome_pathper guard, a sign-in per session guard (attempt(..., guard: 'admin'),logout($request, 'admin'),Guest::guard('admin')), and API tokens bound to their guard (the JWTguardclaim and the token table'sguardcolumn). Policy methods typed for one kind of user refuse the others. - Authorization:
Gatewith policies per model class,before(), defined abilities, andauthorize()(403); the Twig functionsauth_user()andcan(). crystoline/xpsr-auth-jwt: JWTs for APIs onlcobucci/jwt, the default driver of theapiguard, withJwtIssuer, HS256 to HS512, RS256, and ES256, andphp xpsr auth:jwt-secretandauth:jwt. See its README.- In
crystoline/xpsr-database: users from a table (auth.usersdriverdatabase), and revocable API tokens (guard drivertoken) withTokenRepository,php xpsr make:token-table,auth:token, andauth:token-revoke. - In
crystoline/xpsr-orm-doctrine: users as entities (auth.usersdriverdoctrine). TestCase::actingAs()in application tests.- Sessions in the framework (
Crystoline\Xpsr\Session\):SessionMiddlewareandSession::of($request)with values, flash values,regenerate(), andinvalidate(); file and array stores;config/session.php;SessionServiceRegistry; andphp xpsr session:prune. Sessions do not use PHP'ssession_start(), store only JSON data, and set a cookie only once they hold something. See docs/SESSIONS.md. - CSRF protection:
CsrfMiddleware(403 without the session's token in_tokenorX-CSRF-Token), and the Twig functionscsrf_field(),csrf_token(), andsession(). - Session stores in packages:
CacheSessionStoreincrystoline/xpsr-cache(SESSION_STORE=cache), andDatabaseSessionStorewithphp xpsr make:session-tableincrystoline/xpsr-database(SESSION_STORE=database).Crystoline\Xpsr\Testing\SessionStoreContractTestCasechecks custom stores. Crystoline\Xpsr\Http\Cookie, forSet-Cookieheaders with safe defaults.ResponseBuilder::redirect(), 303 by default, andViewHandler::addFunction().- Application tests keep cookies between requests, and
TestCasegainspostForm(),csrfToken(),withSession(),session(), andsend().
Changed
- New projects have
view_engineinconfig/app.php. - New projects list
AuthServiceRegistryinapp.registry, haveconfig/auth.php, and runAuthenticate::optional()in theroutes/web.phpgroup..env.examplehasAUTH_API_DRIVERandAUTH_JWT_SECRET. - New projects list
SessionServiceRegistryinapp.registry, androutes/web.phpputs its routes in a group withSessionMiddlewareandCsrfMiddleware. The skeleton's exampleApp\Http\Middleware\SessionMiddlewareandApp\Registry\SessionServiceRegistry, which did not work, are removed, andconfig/session.phptakes new options. crystoline/xpsr-cacheandcrystoline/xpsr-databaserequirepsr/clock.- Every package's
composer.jsonlinks to the documentation website, xpsr.crysto.name.ng.
0.3.0 (2026-10-28)
Nothing breaks; upgrade steps are in UPGRADE.md.
Added
crystoline/xpsr-log-monolog: an optional package that makes Monolog the application logger, writing to stderr, a file, daily files, or syslog (LOG_CHANNEL), as lines or JSON (LOG_FORMAT, JSON by default in production), with a request ID on every line fromRequestIdMiddleware, which also echoes it inX-Request-Id. See its README.- Rate limiting in
crystoline/xpsr-cache: named sliding-window limits inconfig/rate_limit.php, attached with->middleware(RateLimit::named('login'))or throughapp.middleware. Clients are counted by address through the trusted proxies, or by a custom key resolver. Over the limit, 429 withRetry-After;X-RateLimit-LimitandX-RateLimit-Remainingon every response. See its README. - Request validation. The framework defines
Crystoline\Xpsr\Validation\ValidatorInterface,ValidationException(422 with field errors, shown in every environment), andInputMapper, which maps the body, query string, or route parameters onto input classes: field names from#[Field]and#[Input(naming: 'snake')], filters (Trim,Lowercase,Uppercase,EmptyToNull,Replace, custom#[Filter]), and type conversion reported as field errors. The newcrystoline/xpsr-validationpackage implements the rules with Symfony Validator and registersmake:input. See its README. Crystoline\Xpsr\Http\Exception\PublicErrorInterface: an error whose message and details the exception handler shows to clients in every environment.Crystoline\Xpsr\Http\ClientIp: the client address of a request, read through the trusted proxies'X-Forwarded-Forchain.
0.2.0 (2026-10-28)
Upgrade steps are in UPGRADE.md.
Breaking
- Every class moves from
Crystoline\toCrystoline\Xpsr\, for exampleCrystoline\ApplicationtoCrystoline\Xpsr\Application,Crystoline\Cache\CacheServiceRegistrytoCrystoline\Xpsr\Cache\CacheServiceRegistry, andCrystoline\Router\FastRoute\FastRouteRoutertoCrystoline\Xpsr\Router\FastRoute\FastRouteRouter. The vendor namespace is no longer taken by one product, and it matches thecrystoline/xpsr-*package names. There are no aliases for the old names;UPGRADE.mdhas a command that rewrites them. - The
database_path()helper is removed. It pointed at adatabase/directory XPSR does not use; migrations live inmigrations/. The 0.1 skeleton'sconfig/database.phpcalls it, so replace or delete that file when upgrading.
Added
- PSR-14 events:
Crystoline\Xpsr\Events\EventDispatcher,ListenerProvider, and the optionalStoppableEventbase class, registered byCrystoline\Xpsr\Registry\EventServiceRegistry. Listeners are listed inconfig/events.phpor added by providers with a priority. See docs/EVENTS.md. php xpsr event:list,make:event, andmake:listener.- New projects list
EventServiceRegistryinapp.registryand includeconfig/events.php. Existing projects can add both to use events. crystoline/xpsr-http-client: an optional package providing a configured PSR-18 client, named clients (http_client.<name>) with their own base URL and headers, Symfony'sHttpClientInterface, andTesting\FakeClientfor tests. Options live inconfig/http_client.php. See its README.swap($id, $replacement)in the applicationtests/TestCase.php, to replace a service in a test.crystoline/xpsr-database: an optional package providing Doctrine DBAL connections configured inconfig/database.php, and migrations withmigrate,migrate:rollback,migrate:status, andmake:migration. In production, migrations need--forceor a confirmation. See its README.- Database extension points: the migrate commands use
MigratorInterface, chosen withdatabase.migrations.runner, so another runner can replace XPSR's;Testing\MigratorContractTestCasechecks a runner;Testing\DatabaseTransactionsrolls back each test's database writes. crystoline/xpsr-orm-doctrine: an optional Doctrine ORM 3 adapter. TheEntityManagerruns on XPSR's connection, with attribute-mapped entities inapp/Entity, snake-case columns, PHP 8.4 native lazy objects instead of proxy classes, and the application cache for metadata in production. Includes Doctrine's read-only and cache commands, an XPSRorm:validate-schema, andClearEntityManagerMiddlewarefor worker servers. See its README.php xpsr make:migration <name> --diffwrites a migration from the difference between the database and an expected schema, such as Doctrine's entity mapping. Only tables the schema names are compared.Schema\SchemaProviderInterfacelets an ORM adapter supply the schema;Testing\OrmAdapterContractTestCasechecks ORM adapters.- CORS:
Crystoline\Xpsr\Http\Middleware\CorsMiddleware, configured inconfig/cors.phpandCORS_ALLOWED_ORIGINS. It answers preflight requests, adds the headers to every response from an allowed origin, error responses included, and supports subdomain patterns. New projects list it first inapp.middleware; it is inactive until an origin is allowed. See docs/HTTP.md. - PSR-20 clock:
Psr\Clock\ClockInterfaceis registered as asymfony/clockNativeClockinapp.timezone. The applicationTestCasegainsfreezeTime().
Changed
- The skeleton's
config/database.phpis replaced: the previous file was unused and came from another framework. The new one configures SQLite, MySQL, and PostgreSQL connections forcrystoline/xpsr-database.
Fixed
- Route building is 18 to 48 times faster. Route patterns were parsed again on every use, and constraints re-validated each time, so 300 routes took about 276 ms per request; they now take about 6 ms.
RouteCollection::named()and URL generation no longer scan every route.
0.1.1 (2026-10-28)
Fixed
php xpsr aboutandphp xpsr --versionreport the installed framework version, such as0.1.1, instead of always sayingdevelopment.
Deprecated
Crystoline\Console\ConsoleKernel::VERSION. UseConsoleKernel::version(). The constant still exists and still equalsdevelopment.
0.1.0 (2026-10-28)
First release of the rebuilt framework. It replaces the earlier prototype, which was published as crystoline/xpsr v1.0.0; there is no upgrade path from it. Start a new project and move application code across.
Added
- Packages:
crystoline/xpsr(skeleton),crystoline/xpsr-framework,crystoline/xpsr-router-fastroute, andcrystoline/xpsr-router-sunrise. - Application bootstrap with
.envloading and per-environment configuration overrides (config/{APP_ENV}/). - PSR-11 container with explicit bindings, autowiring, aliases, and eager or deferred service providers with register/boot stages.
- PSR-15 HTTP kernel, global and route middleware, body parsing for JSON and non-POST forms, and a response emitter.
- Portable route definitions with groups, names, constraints, defaults, and URL generation, served by the FastRoute or Sunrise adapter. HEAD, OPTIONS, 404, and 405 are handled.
- JSON or HTML error rendering by content negotiation; exception details only with
APP_DEBUG=true. Server errors are logged through PSR-3 (error_log()by default). - Forwarding headers trusted only from
TRUSTED_PROXIES. xpsrconsole withabout,route:list,serve,setup, and themake:controller,make:middleware,make:command, andmake:providergenerators.- Application test scaffold (
tests/TestCase.php) with request and console helpers. - Router contract test suite for adapter authors (
Crystoline\Testing\RouterContractTestCase). - Documentation: getting started, deployment, HTTP, routing, container, console, packages, and versioning.
Cache
crystoline/xpsr-cache: an optional package providing the application cache as PSR-16 and PSR-6, backed by Symfony Cache, withfile,array,null, andservicedrivers and acache:clearcommand.
HTTP exceptions
Crystoline\Http\Exception\*:HttpException,HttpExceptionInterface, and classes for 400, 401, 403, 404, 405, 409, 413, 415, 422, 429, and 503. The framework no longer depends onsymfony/http-kernelorsymfony/http-foundation. Symfony's HttpKernel exceptions are still rendered with their status when that package is installed.
Optional dependencies
twig/twig(views) andfilp/whoops(debug error pages) are suggestions of the framework, not requirements. The skeleton requires Twig for its web example and installs Whoops for development only. Without Whoops, debug mode shows a plain built-in error page.
Requirements
- PHP 8.4 or 8.5.