Changelog
All XPSR packages share one version number. See docs/VERSIONING.md for what each kind of release promises.
Unreleased
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.