Coming from Laravel
This guide moves a Laravel application, such as a JSON API with Sanctum tokens, queues, and notifications, to XPSR. Much of what Laravel stores carries over as it is, so users keep their accounts, tokens, and data. The code changes more: XPSR passes services in by type instead of using facades, and its requests and responses are PSR-7 messages.
What carries over
| Laravel's | In XPSR |
|---|---|
APP_KEY and the values encrypted with it |
Read and written in the same format, including Eloquent's encrypted casts. Copy APP_KEY and APP_PREVIOUS_KEYS. See Encryption. |
Sanctum tokens in personal_access_tokens |
Accepted by the sanctum guard driver; abilities are scopes. See Authentication. |
| bcrypt password hashes | Verified, and upgraded to Argon2id when the user next signs in. |
| The database | php xpsr migrate:baseline starts XPSR's migrations from it. See the database package. |
| Eloquent models | Run on XPSR's connections with crystoline/xpsr-orm-eloquent, after the changes in step 4. |
The notifications table, and push_subscriptions from laravel-notification-channels/webpush |
Read and written as Laravel does, with the same VAPID_* keys. See the notifications package. |
These do not carry over:
- Sessions and cookies. XPSR neither reads Laravel's sessions nor decrypts its cookies, so people signed in through the browser sign in once more. Sanctum's cookie sign-in for single-page apps is not supported; use XPSR's session guard.
- Queued jobs. XPSR's workers cannot run jobs Laravel queued. Let Laravel's workers empty the queues before XPSR takes over.
- The cache, rate limit counts, and locks start empty.
Moving, step by step
1. Create the project
composer create-project crystoline/xpsr my-api
cd my-api
php xpsr setup --router=fastroute --preset=api
composer require crystoline/xpsr-database crystoline/xpsr-orm-eloquent crystoline/xpsr-validation \
crystoline/xpsr-cache crystoline/xpsr-lock crystoline/xpsr-queue crystoline/xpsr-mail \
crystoline/xpsr-notifications crystoline/xpsr-scheduler crystoline/xpsr-storage \
crystoline/xpsr-http-client crystoline/xpsr-log-monolog
Install only the packages the application uses. Each one has a provider to list in app.registry in config/app.php, database first:
Crystoline\Xpsr\Database\DatabaseServiceRegistry::class,
Crystoline\Xpsr\Orm\Eloquent\EloquentServiceRegistry::class,
Crystoline\Xpsr\Validation\Symfony\ValidationServiceRegistry::class,
Crystoline\Xpsr\Cache\CacheServiceRegistry::class,
Crystoline\Xpsr\Lock\LockServiceRegistry::class,
Crystoline\Xpsr\Queue\QueueServiceRegistry::class,
Crystoline\Xpsr\Mail\MailServiceRegistry::class,
Crystoline\Xpsr\Notifications\NotificationServiceRegistry::class,
Crystoline\Xpsr\Scheduler\SchedulerServiceRegistry::class,
Crystoline\Xpsr\Storage\StorageServiceRegistry::class,
Crystoline\Xpsr\HttpClient\HttpClientServiceRegistry::class,
Crystoline\Xpsr\Log\Monolog\MonologServiceRegistry::class, // right after FactoryRegistry
Configuration lives in config/*.php files that read .env through env(), as in Laravel, but each package has its own options; its README shows them.
2. The environment
- Keys: copy
APP_KEY, andAPP_PREVIOUS_KEYSif it is set. Keepapp.cipheras Laravel's (AES-256-CBCunless it was changed). - Time zone: keep
timezoneinconfig/app.phpthe same as Laravel's. Sanctum's and the notifications' times are stored in it, as Laravel stores them. - Database:
DB_CONNECTION,DB_HOST,DB_PORT,DB_DATABASE,DB_USERNAME, andDB_PASSWORDhave the same names. XPSR's connections aresqlite,mysql, andpgsql; for Laravel'smariadbconnection, usemysql. - Services by DSN: where Laravel has several variables per service, XPSR takes one DSN:
MAIL_DSN=smtp://user:secret@smtp.example.com:587,QUEUE_DSN=redis://127.0.0.1:6379/messages,SMS_DSN=termii://API_KEY@default?from=MPAY&channel=generic, andLOCK_STORE=redis://127.0.0.1:6379. - Web push: copy
VAPID_PUBLIC_KEYandVAPID_PRIVATE_KEY, so subscribed browsers keep getting pushes.
Three of Laravel's tables have names XPSR uses for tables of its own, laid out differently. Give XPSR's other names:
| Laravel's table | XPSR option | For example |
|---|---|---|
migrations |
migrations.table in config/database.php |
xpsr_migrations |
failed_jobs |
failed.table in config/queue.php |
xpsr_failed_jobs |
sessions (database sessions only) |
database.table in config/session.php |
xpsr_sessions |
The migration commands stop with a message if migrations.table names Laravel's table.
3. The database
With .env pointing at the Laravel database, or at a copy of it:
php xpsr migrate:baseline --exclude=cache --exclude=cache_locks --exclude=jobs --exclude=job_batches
The baseline describes every table, so a new database, such as the one tests use, gets the same schema. Tables named with --exclude stay in the database but are left out of the baseline. On staging and production, the same command only records the baseline. Run it before any other XPSR migration; then create XPSR's own tables, such as php xpsr make:queue-table, and run php xpsr migrate.
From then on, change the schema with XPSR migrations (php xpsr make:migration), written with DBAL's schema API. Eloquent's schema builder and Laravel's migration files are not used.
4. Models
Copy app/Models. Models keep their casts, relationships, scopes, accessors, events, and soft deletes, and encrypted casts use XPSR's encrypter. Remove Laravel's traits that need the rest of Laravel, such as HasApiTokens, Notifiable, and HasFactory, and implement XPSR's interfaces instead:
use Crystoline\Xpsr\Auth\UserInterface;
use Crystoline\Xpsr\Notifications\{NotifiableInterface, NotifiableKey};
use Crystoline\Xpsr\Orm\Eloquent\Auth\Authenticatable;
class User extends Model implements UserInterface, NotifiableInterface
{
use Authenticatable;
public function notificationAddress(string $channel): ?string
{
return match ($channel) {
'mail' => $this->email,
'sms' => $this->phone,
default => null,
};
}
public function notifiableKey(): NotifiableKey
{
return new NotifiableKey(self::class, $this->id); // Laravel's notifiable_type: the class, or its morph map alias
}
}
paginate() needs illuminate/pagination, and model factories need fakerphp/faker.
5. Authentication
In config/auth.php, a guard with the sanctum driver for each kind of user, and the users from the Eloquent models:
'guards' => [
'api' => ['driver' => 'sanctum', 'provider' => 'users'],
'admin_api' => ['driver' => 'sanctum', 'provider' => 'admins'],
],
'providers' => [
'users' => ['driver' => 'eloquent', 'model' => App\Models\User::class, 'login' => 'email'],
'admins' => ['driver' => 'eloquent', 'model' => App\Models\Admin::class, 'login' => 'email'],
],
Each guard accepts only the tokens whose tokenable_type is its model, so an admin's token never counts on the customers' API. Routes use Authenticate::guard('api') where Laravel had auth:sanctum, and RequireScopes::of('payments:create') where it checked tokenCan(). Policies are listed in config/auth.php and checked with Crystoline\Xpsr\Auth\Access\Gate. See Authentication.
Registration, password reset, email verification, and two-factor sign-in stay application code, as they may already be.
6. Routes and controllers
// routes/api.php
return static function (RouteCollection $routes): void {
$routes->group('/api', static function (RouteCollection $routes): void {
$routes->get('/payments/{reference}', [PaymentController::class, 'show']);
$routes->post('/payments', [PaymentController::class, 'store'])->middleware(RequireScopes::of('payments:create'));
}, [Authenticate::guard('api'), RateLimit::named('api')]);
};
final class PaymentController
{
public function __construct(
private readonly ResponseBuilder $responses,
private readonly ValidatorInterface $validator,
private readonly Gate $gate,
) {}
public function show(ServerRequestInterface $request): ResponseInterface
{
$payment = Payment::query()->where('reference', $request->getAttribute('reference'))->first()
?? throw new NotFoundHttpException('No such payment.');
$this->gate->authorize(Identity::of($request)->user, 'view', $payment);
return $this->responses->json($payment->toArray());
}
public function store(ServerRequestInterface $request): ResponseInterface
{
$input = $this->validator->validate($request, CreatePayment::class); // 422 with "message" and "errors" by field, as Laravel
// ...
}
}
- A controller method takes the request and returns a response. Its services come through the constructor. Route parameters are request attributes; there is no route model binding.
- Form requests become input classes (
php xpsr make:input CreatePayment): constructor parameters with Symfony Validator attributes. Authorization moves to the gate. - Middleware is PSR-15 (
php xpsr make:middleware).throttle:apibecomesRateLimit::named('api'), with the limits inconfig/rate_limit.php. - Errors: throw an exception from
Crystoline\Xpsr\Http\Exception, such asNotFoundHttpException, where Laravel callsabort(). JSON clients get{"message": ...}. - API resources become arrays, or objects that implement
JsonSerializable.
7. Jobs, mail, notifications, events, and the schedule
- Jobs implement
Crystoline\Xpsr\Queue\JobInterface. Theirhandle()methods take services by type, as Laravel's do.ShouldBeUniquebecomes#[Unique], andonQueue()becomes#[OnQueue]or aqueue:argument. Retries are set for each transport. Keep a job's data to IDs and plain values: XPSR does not reload models the waySerializesModelsdoes. - Mailables become Symfony
Emailmessages, usually from a view:$mailer->compose('emails.receipt', [...]). - Notifications implement one interface per channel instead of
via(). See the notifications package. - Events are PSR-14: dispatch with
EventDispatcherInterface, and list listeners inconfig/events.php. Listeners run at once; one that should run later dispatches a job. - The schedule moves to
routes/schedule.php, and cron runsphp xpsr schedule:run.
8. Tests
The skeleton's tests/TestCase.php has get(), postJson(), request(), actingAs(), console(), swap(), and freezeTime().
| Laravel | XPSR |
|---|---|
RefreshDatabase |
DatabaseTransactions, after one migrate |
Queue::fake() |
$this->swap(QueueInterface::class, new FakeQueue()) |
Notification::fake() |
$this->swap(NotifierInterface::class, $this->application()->get(FakeNotifier::class)) |
Mail::fake() |
MAIL_DSN=array://default, then the mail package's ArrayTransport |
Storage::fake('s3') |
$this->application()->get(StorageManager::class)->fake('s3') |
Http::fake() |
$this->swap(ClientInterface::class, new FakeClient(...)) |
Carbon::setTestNow() |
freezeTime() for XPSR's clock; Eloquent's timestamps use Carbon, so set both when a test depends on them |
Laravel to XPSR
| Laravel | XPSR |
|---|---|
Facades, app(), resolve() |
Constructor parameters, by type. app()->get(...) and config() exist for the rare place without injection. |
Route::get() |
$routes->get() in routes/*.php |
$request->input('amount') |
An input class, or $request->getParsedBody()['amount'] |
response()->json() |
ResponseBuilder::json() |
abort(404) |
throw new NotFoundHttpException() |
$request->user(), auth()->user() |
Identity::of($request)->user |
Auth::attempt() |
AuthManager::attempt() |
Hash::make(), Hash::check() |
PasswordHasher::hash(), verify() |
Gate::authorize(), $this->authorize() |
Gate::authorize() |
$user->createToken() |
SanctumTokenRepository::create() |
DB::table(), DB::select() |
Doctrine DBAL's Connection, or Eloquent |
Crypt::encryptString() |
Encrypter::encryptString() |
Cache::remember() |
PSR-16 CacheInterface: get(), then set() |
Cache::lock() |
Locks::run() |
RateLimiter::attempt(), hit(), clear() |
RateLimits::attempt(), hit(), reset() |
dispatch(), Job::dispatch() |
QueueInterface::dispatch() |
Mail::to()->send() |
Mailer::send(), Mailer::queue() |
$user->notify() |
NotifierInterface::send(), queue() |
event() |
EventDispatcherInterface::dispatch() |
Storage::disk() |
StorageManager::disk() |
Http::get() |
PSR-18 ClientInterface, and named clients |
Log::info() |
PSR-3 LoggerInterface |
php artisan ... |
php xpsr ...: migrate, queue:work, queue:retry, schedule:run, make:* |
Running both side by side
An application can move a route group or a job at a time, with Laravel and XPSR on the same database:
- Shared: the database,
APP_KEY, the time zone, Sanctum tokens (each accepts the other's), encrypted columns, and the notifications and push subscriptions tables. - Passwords. XPSR stores a new Argon2id hash when a user with a bcrypt hash signs in. Laravel's bcrypt hasher refuses a hash that is not bcrypt while
HASH_VERIFYis on, as it is by default in Laravel 12, and the sign-in fails with an error. SetHASH_VERIFY=falsein Laravel's.envwhile both run: Laravel then checks both kinds, and may store a bcrypt hash again when the user signs in there, which XPSR also accepts. - Schema changes happen in XPSR's migrations once it has its baseline, so each side keeps its own migrations table.
- Queues. Give each side its own queue: XPSR's workers cannot run Laravel's jobs, nor Laravel's XPSR's.
- The schedule. Run each task on one side only.
- Locks and rate limits are not shared. A lock Laravel holds does not stop XPSR, and the reverse. Move all the code that must not run twice at once, such as capturing a payment, in one step.
Not in XPSR
- Facades, route model binding, API resources, form request
authorize(), and service injection into controller methods. - Broadcasting, Horizon, Telescope, Pulse, Socialite, Passport, Cashier, and Scout.
- Octane: long-running servers have not been verified (see Deployment).
- Registration, password reset, email verification, two-factor sign-in, and "remember me" cookies.
- Translations: validation messages are in English.
- Tinker, and route and configuration caches, which XPSR does not need (see Deployment).