Bekzod Erkinov
Listen to Article
Loading...Table of contents · 11 sections
Advanced Laravel Tutorial: Queues, Events and Pest Tests
Dispatch an event inside a database transaction, let the transaction roll back, and a queued listener can still be sitting on the queue for a row that never existed. This tutorial builds a small subscription-billing app in Laravel 12, proves that failure with a test, then fixes it with one interface. You get action classes, a form request, a policy, queued listeners, a scheduled fan-out job and a 19-test Pest suite.
There is no repository to clone. Every file I wrote is listed below in full (the opening <?php line is omitted from each PHP listing), so you can rebuild it in Docker.
Versions, as printed
Run on 2026-10-11 inside the container built below:
$ php artisan --version
Laravel Framework 12.69.3
$ ./vendor/bin/pest --version
Pest Testing Framework 4.7.8.
$ php -v | head -1
PHP 8.4.26 (cli) (built: Oct 6 2026 01:25:39) (NTS)
composer show printed versions : * v4.7.8 for pestphp/pest and 12.5.33 for phpunit/phpunit. Pest 4.7.8 requires php ^8.3.0 and phpunit/phpunit ^12.5.33.
Why Pest 4? The Laravel 12 skeleton's composer.json requires "pestphp/pest": "^4.7" and "pestphp/pest-plugin-laravel": "^4.1". Running composer why-not pestphp/pest 5.0.0 in this install printed, among other lines, that v5.0.0 requires brianium/paratest (^7.23.0) and pestphp/pest-plugin (^5.0.0) while v7.20.0 and v4.0.0 are installed. The Pest installation page says "Pest requires PHP 8.4+ to run"; the page doesn't name a Pest version in its text, but its navigation advertises "Pest 5 Now Available" and its edit link points at a 5.x branch, so I read it as the Pest 5 docs. Everything here uses the skeleton's Pest 4.
The payment gateway is a local fake with no network calls. This is not a production system.
Set up Docker
Dockerfile:
FROM php:8.4-cli
RUN apt-get update && apt-get install -y git unzip libzip-dev && docker-php-ext-install pcntl zip && rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /app
The queue docs say job timeouts need the PCNTL extension, hence pcntl. Build, create the project, run the suite (bash; mount the project at /w):
docker build -t tix-php .
docker run --rm -v "$PWD:/w" -w /w tix-php composer create-project laravel/laravel:^12.0 app --no-interaction
docker run --rm -v "$PWD:/w" -w /w/app tix-php ./vendor/bin/pest
In tests/Pest.php, replace the pest()->extend(...) block so feature tests refresh the database:
pest()->extend(TestCase::class)
->use(Illuminate\Foundation\Testing\RefreshDatabase::class)
->in('Feature');
Everything not listed below is the stock skeleton.
What you're building
A user picks a plan, gets an active subscription and a first invoice, and gets a welcome email from a queue worker. Every hour a scheduled command finds due subscriptions and queues one renewal job each. A declined charge flips the subscription to past_due.
Data layer
// database/migrations/2026_10_11_000001_create_billing_tables.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('plans', function (Blueprint $t) {
$t->id();
$t->string('name');
$t->unsignedInteger('price_cents');
$t->unsignedSmallInteger('interval_days')->default(30);
$t->boolean('active')->default(true);
$t->timestamps();
});
Schema::create('subscriptions', function (Blueprint $t) {
$t->id();
$t->foreignId('user_id')->constrained()->cascadeOnDelete();
$t->foreignId('plan_id')->constrained();
$t->string('status')->default('active')->index(); // active | past_due | cancelled
$t->timestamp('renews_at')->nullable()->index();
$t->timestamp('cancelled_at')->nullable();
$t->timestamps();
});
// At most one active subscription per user, enforced by the database.
DB::statement("CREATE UNIQUE INDEX one_active_subscription_per_user ON subscriptions (user_id) WHERE status = 'active'");
Schema::create('invoices', function (Blueprint $t) {
$t->id();
$t->foreignId('subscription_id')->constrained()->cascadeOnDelete();
$t->unsignedInteger('amount_cents');
$t->string('status'); // paid | failed
$t->timestamp('period_start');
$t->timestamps();
});
}
};
The CREATE UNIQUE INDEX ... WHERE statement is raw SQL because Laravel's schema builder has no partial-index helper that I used. I ran it on SQLite only; check the syntax for your database before copying it.
// app/Models/Plan.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Plan extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return ['active' => 'boolean'];
}
}
// app/Models/Invoice.php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Invoice extends Model
{
protected $guarded = [];
protected function casts(): array
{
return ['period_start' => 'datetime'];
}
}
// app/Models/Subscription.php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Subscription extends Model
{
use HasFactory;
protected $guarded = [];
protected function casts(): array
{
return ['renews_at' => 'datetime', 'cancelled_at' => 'datetime'];
}
public function user(): BelongsTo { return $this->belongsTo(User::class); }
public function plan(): BelongsTo { return $this->belongsTo(Plan::class); }
public function invoices(): HasMany { return $this->hasMany(Invoice::class); }
}
In the stock app/Models/User.php, add use Illuminate\Database\Eloquent\Relations\HasMany; and this method:
public function subscriptions(): HasMany
{
return $this->hasMany(Subscription::class);
}
// database/factories/PlanFactory.php
namespace Database\Factories;
use Illuminate\Database\Eloquent\Factories\Factory;
class PlanFactory extends Factory
{
public function definition(): array
{
return ['name' => 'Pro', 'price_cents' => 1900, 'interval_days' => 30, 'active' => true];
}
}
// database/factories/SubscriptionFactory.php
namespace Database\Factories;
use App\Models\Plan;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class SubscriptionFactory extends Factory
{
public function definition(): array
{
return [
'user_id' => User::factory(),
'plan_id' => Plan::factory(),
'status' => 'active',
'renews_at' => now()->addDays(30),
];
}
}
The gateway is an interface bound to a fake that declines any email starting with decline:
// app/Contracts/PaymentGateway.php
namespace App\Contracts;
use App\Models\User;
interface PaymentGateway
{
/** Returns true when the charge succeeded. */
public function charge(User $user, int $amountCents): bool;
}
// app/Services/FakeGateway.php
namespace App\Services;
use App\Contracts\PaymentGateway;
use App\Models\User;
class FakeGateway implements PaymentGateway
{
public function charge(User $user, int $amountCents): bool
{
return ! str_starts_with($user->email, 'decline');
}
}
In app/Providers/AppServiceProvider.php, add use App\Contracts\PaymentGateway; and use App\Services\FakeGateway;, then inside register():
$this->app->bind(PaymentGateway::class, FakeGateway::class);
{{-- resources/views/mail/welcome.blade.php --}}
<p>Your {{ $subscription->plan->name }} plan is active. Next renewal: {{ $subscription->renews_at->toDateString() }}.</p>
// app/Mail/WelcomeMail.php
namespace App\Mail;
use App\Models\Subscription;
use Illuminate\Mail\Mailable;
class WelcomeMail extends Mailable
{
public function __construct(public Subscription $subscription) {}
public function build(): static
{
return $this->subject('Welcome to '.$this->subscription->plan->name)
->view('mail.welcome');
}
}
Put the business logic in action classes
The controller translates HTTP into a call and a response:
// app/Http/Controllers/SubscriptionController.php
namespace App\Http\Controllers;
use App\Actions\CancelSubscription;
use App\Actions\StartSubscription;
use App\Http\Requests\StoreSubscriptionRequest;
use App\Models\Subscription;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Gate;
class SubscriptionController extends Controller
{
public function store(StoreSubscriptionRequest $request, StartSubscription $start): JsonResponse
{
$sub = $start($request->user(), $request->plan());
return response()->json($sub->load('plan'), 201);
}
public function show(Subscription $subscription): JsonResponse
{
Gate::authorize('view', $subscription);
return response()->json($subscription->load('plan', 'invoices'));
}
public function destroy(Subscription $subscription, CancelSubscription $cancel): JsonResponse
{
Gate::authorize('cancel', $subscription);
return response()->json($cancel($subscription));
}
}
In routes/web.php, add use App\Http\Controllers\SubscriptionController; and:
Route::middleware('auth')->group(function () {
Route::post('/subscriptions', [SubscriptionController::class, 'store']);
Route::get('/subscriptions/{subscription}', [SubscriptionController::class, 'show']);
Route::delete('/subscriptions/{subscription}', [SubscriptionController::class, 'destroy']);
});
Gotcha: $this->authorize() is gone from the base controller. My first run failed with Call to undefined method App\Http\Controllers\SubscriptionController::authorize(). The Laravel 12 skeleton's base Controller is an empty abstract class (the Controller.php in this install contains only abstract class Controller { // }). Use Gate::authorize(), which throws the 403 itself, or add the AuthorizesRequests trait back.
When an action class earns its place: when the operation touches more than one table or dispatches an event. StartSubscription writes a subscription and an invoice in one transaction and fires an event; the CLI will want to call it, and a model shouldn't know about invoices and mail. CancelSubscription is borderline; it's there so the controller stays uniform.
// app/Actions/StartSubscription.php
namespace App\Actions;
use App\Events\SubscriptionStarted;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
class StartSubscription
{
public function __invoke(User $user, Plan $plan): Subscription
{
try {
return $this->start($user, $plan);
} catch (UniqueConstraintViolationException) {
// Lost the race that the form request's read-then-write check cannot close.
throw ValidationException::withMessages(['plan_id' => 'You already have an active subscription.']);
}
}
private function start(User $user, Plan $plan): Subscription
{
return DB::transaction(function () use ($user, $plan) {
$sub = Subscription::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'status' => 'active',
'renews_at' => now()->addDays($plan->interval_days),
]);
$sub->invoices()->create([
'amount_cents' => $plan->price_cents,
'status' => 'paid',
'period_start' => now(),
]);
// Fires only after COMMIT (ShouldDispatchAfterCommit on the event).
SubscriptionStarted::dispatch($sub);
return $sub;
});
}
}
// app/Actions/CancelSubscription.php
namespace App\Actions;
use App\Models\Subscription;
class CancelSubscription
{
public function __invoke(Subscription $sub): Subscription
{
$sub->update(['status' => 'cancelled', 'cancelled_at' => now(), 'renews_at' => null]);
return $sub;
}
}
Validate with a form request, and back it with a database constraint
The plan must exist and be active, and a user can't have two active subscriptions.
// app/Http/Requests/StoreSubscriptionRequest.php
namespace App\Http\Requests;
use App\Models\Plan;
use Illuminate\Contracts\Validation\Validator;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreSubscriptionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user() !== null;
}
public function rules(): array
{
return [
'plan_id' => ['required', 'integer', Rule::exists('plans', 'id')->where('active', true)],
];
}
public function after(): array
{
return [function (Validator $v) {
if ($this->user()->subscriptions()->where('status', 'active')->exists()) {
$v->errors()->add('plan_id', 'You already have an active subscription.');
}
}];
}
public function plan(): Plan
{
return Plan::findOrFail($this->validated('plan_id'));
}
}
The "no second active subscription" rule in after() is a read followed by a write, so two simultaneous requests can both pass it. The partial unique index in the migration is the backstop, and StartSubscription turns the resulting UniqueConstraintViolationException into the same validation error. My test calls the action twice in a row, which exercises the index but is not a concurrent-request test.
A dataset in the suite covers an inactive plan, an unknown id and the string abc in one test.
Authorize with a policy
// app/Policies/SubscriptionPolicy.php
namespace App\Policies;
use App\Models\Subscription;
use App\Models\User;
class SubscriptionPolicy
{
public function view(User $user, Subscription $sub): bool
{
return $sub->user_id === $user->id;
}
public function cancel(User $user, Subscription $sub): bool
{
return $sub->user_id === $user->id && $sub->status !== 'cancelled';
}
}
Nothing is registered. The authorization docs say Laravel discovers policies automatically when the model and policy follow its naming conventions: a Policies directory at or above the models directory, and a policy name equal to the model name plus Policy. To check it here instead of inferring it from passing tests, I asked the gate in tinker:
$ php artisan tinker --execute='echo get_class(Gate::getPolicyFor(App\Models\Subscription::class));'
App\Policies\SubscriptionPolicy
The test then checks a stranger gets 403 on both verbs, the owner can cancel once, and a second cancel is 403.
Laravel queued listeners and events: dispatch after commit
Event discovery scans app/Listeners for handle or __invoke methods and reads the event from the type hint, per the events docs. php artisan event:list showed it working with no registration code:
App\Events\RenewalFailed
⇂ App\Listeners\MarkPastDue@handle (ShouldQueue)
App\Events\SubscriptionStarted
⇂ App\Listeners\SendWelcomeEmail@handle (ShouldQueue)
// app/Events/SubscriptionStarted.php
namespace App\Events;
use App\Models\Subscription;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
class SubscriptionStarted implements ShouldDispatchAfterCommit
{
use Dispatchable;
public function __construct(public Subscription $subscription) {}
}
// app/Events/RenewalFailed.php
namespace App\Events;
use App\Models\Subscription;
use Illuminate\Contracts\Events\ShouldDispatchAfterCommit;
use Illuminate\Foundation\Events\Dispatchable;
class RenewalFailed implements ShouldDispatchAfterCommit
{
use Dispatchable;
public function __construct(
public Subscription $subscription,
) {}
}
// app/Listeners/SendWelcomeEmail.php
namespace App\Listeners;
use App\Events\SubscriptionStarted;
use App\Mail\WelcomeMail;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Mail;
class SendWelcomeEmail implements ShouldQueue
{
public string $queue = 'mail';
public int $tries = 3;
public function backoff(): array
{
return [10, 60];
}
public function handle(SubscriptionStarted $event): void
{
$sub = $event->subscription->loadMissing('user', 'plan');
Mail::to($sub->user)->send(new WelcomeMail($sub));
}
}
// app/Listeners/MarkPastDue.php
namespace App\Listeners;
use App\Events\RenewalFailed;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Support\Facades\Log;
class MarkPastDue implements ShouldQueue
{
public function handle(RenewalFailed $event): void
{
$event->subscription->update(['status' => 'past_due']);
Log::warning('renewal failed', ['subscription' => $event->subscription->id]);
}
}
Both events carry the interface. RenewalFailed is dispatched inside the transaction in RenewSubscription (shown in the scheduler section), right next to the failed invoice insert. Without the interface, a real worker could pick up MarkPastDue before that transaction commits, or after it rolled back.
Prove the ghost job exists
StartSubscription dispatches inside a transaction. The config/queue.php in this skeleton has 'after_commit' => false (a grep of the file printed it on lines 44, 53 and 64). To see what that means, tests/Feature/GhostJobTest.php defines an event identical to SubscriptionStarted but without ShouldDispatchAfterCommit, a queued listener for it, and a transaction that throws right after dispatching. Queue::fake() records what was pushed. I kept the file in the suite:
// tests/Feature/GhostJobTest.php
use App\Events\SubscriptionStarted;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Events\CallQueuedListener;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
class EagerStarted
{
use Dispatchable;
public function __construct(
public Subscription $subscription,
) {}
}
class EagerWelcome implements ShouldQueue
{
public function handle(EagerStarted $event): void {}
}
function startThenRollBack(string $eventClass): void
{
$user = User::factory()->create();
$plan = Plan::factory()->create();
try {
DB::transaction(function () use ($user, $plan, $eventClass) {
$sub = Subscription::create([
'user_id' => $user->id, 'plan_id' => $plan->id,
'status' => 'active', 'renews_at' => now()->addDays(30),
]);
$eventClass::dispatch($sub);
throw new RuntimeException('boom after dispatch');
});
} catch (RuntimeException) {
}
}
it('without after-commit, the listener job is queued for a rolled-back row', function () {
Queue::fake();
Event::listen(EagerStarted::class, EagerWelcome::class);
startThenRollBack(EagerStarted::class);
expect(Subscription::count())->toBe(0);
Queue::assertPushed(CallQueuedListener::class, 1);
});
it('with ShouldDispatchAfterCommit, nothing is queued', function () {
Queue::fake();
startThenRollBack(SubscriptionStarted::class);
expect(Subscription::count())->toBe(0);
Queue::assertNothingPushed();
});
Output from the full-suite run on 2026-10-11:
PASS Tests\Feature\GhostJobTest
✓ it without after-commit, the listener job is queued for a rolled-b…
✓ it with ShouldDispatchAfterCommit, nothing is queued
The subscription count is 0 in both cases, but the plain event left one listener job on the fake queue. On a real worker that job would try to load a subscription that doesn't exist.
How to fix it, per the queue docs and events docs:
| Option | Scope | Use when |
|---|---|---|
'after_commit' => true on the connection |
The queue docs say this also covers queued event listeners, mailables, notifications and broadcast events on that connection | You want it everywhere |
ShouldQueueAfterCommit on a listener |
One listener (named in the events docs) | Only some listeners care |
ShouldDispatchAfterCommit on the event |
The event itself, so no listener runs until commit | The event must not escape a rollback |
I used the third.
Retries: when not to have any
SendWelcomeEmail gets $tries = 3 with a [10, 60] second backoff because sending a welcome mail twice is annoying but harmless. The renewal job gets $tries = 1 because I didn't want an automatic retry to charge a card a second time; with a real gateway, check how it handles a repeated charge before changing that.
Laravel scheduler: a fan-out job per due subscription
The scheduler is one cron line (* * * * * php artisan schedule:run) and your schedule lives in code. The scheduling docs put it in routes/console.php. Add use Illuminate\Support\Facades\Schedule; and:
Schedule::command('subscriptions:renew')
->hourly()
->withoutOverlapping()
->onOneServer();
The command does no billing itself. It queues one job per due row, so one slow or failed charge can't hold up the others:
// app/Console/Commands/RenewDueSubscriptions.php
namespace App\Console\Commands;
use App\Jobs\RenewSubscriptionJob;
use App\Models\Subscription;
use Illuminate\Console\Command;
class RenewDueSubscriptions extends Command
{
protected $signature = 'subscriptions:renew';
protected $description = 'Queue a renewal job for every active subscription that is due';
public function handle(): int
{
$n = 0;
Subscription::where('status', 'active')
->where('renews_at', '<=', now())
->select('id')
->chunkById(200, function ($subs) use (&$n) {
foreach ($subs as $s) {
RenewSubscriptionJob::dispatch($s->id);
$n++;
}
});
$this->info("Queued {$n} renewal(s).");
return self::SUCCESS;
}
}
// app/Jobs/RenewSubscriptionJob.php
namespace App\Jobs;
use App\Actions\RenewSubscription;
use App\Models\Subscription;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
class RenewSubscriptionJob implements ShouldQueue
{
use Queueable;
public int $tries = 1; // a charge is not safe to auto-retry here
public function __construct(public int $subscriptionId) {}
public function handle(RenewSubscription $renew): void
{
if ($sub = Subscription::find($this->subscriptionId)) {
$renew($sub);
}
}
}
The job carries the id, not the model. RenewSubscription re-reads the row inside a transaction and checks it is still due, which is what makes a double run harmless:
// app/Actions/RenewSubscription.php
namespace App\Actions;
use App\Contracts\PaymentGateway;
use App\Events\RenewalFailed;
use App\Models\Subscription;
use Illuminate\Support\Facades\DB;
class RenewSubscription
{
public function __construct(private PaymentGateway $gateway) {}
public function __invoke(Subscription $sub): bool
{
return DB::transaction(function () use ($sub) {
// Re-read inside the transaction: another worker may have renewed it.
$sub = Subscription::whereKey($sub->id)->lockForUpdate()->first();
if ($sub->status !== 'active' || $sub->renews_at === null || $sub->renews_at->isFuture()) {
return false;
}
$plan = $sub->plan;
$ok = $this->gateway->charge($sub->user, $plan->price_cents);
$sub->invoices()->create([
'amount_cents' => $plan->price_cents,
'status' => $ok ? 'paid' : 'failed',
'period_start' => $sub->renews_at,
]);
if ($ok) {
$sub->update(['renews_at' => $sub->renews_at->addDays($plan->interval_days)]);
} else {
RenewalFailed::dispatch($sub); // held until COMMIT
}
return $ok;
});
}
}
What withoutOverlapping and onOneServer give you. Per the scheduling docs, withoutOverlapping takes a cache lock that expires after 24 hours by default, and onOneServer needs a shared database, memcached, dynamodb or redis cache driver. Both are about scheduler invocations starting the same task. Neither looks at jobs already on the queue, so dispatching twice before the first job finishes can still put two jobs for one subscription in front of workers. That's why the action re-checks state: the lock is the second line of defence.
Gotcha: lockForUpdate() does nothing on SQLite. In this install, compileLock() in vendor/laravel/framework/src/Illuminate/Database/Query/Grammars/SQLiteGrammar.php (lines 31-34) returns an empty string. My "safe to run twice" test passes because the first run moves renews_at forward and the second finds nothing due. That checks the state guard, not the lock, and I did not test the lock on a database server.
Running it for real
The suite uses the sync queue, so I also ran the database queue with a worker. demo.php creates one normal user and one whose email starts with decline, each with a subscription:
use App\Models\{User,Plan};
$u = User::factory()->create(['email'=>'[email protected]']);
$d = User::factory()->create(['email'=>'[email protected]']);
$plan = Plan::factory()->create();
app(App\Actions\StartSubscription::class)($u, $plan);
app(App\Actions\StartSubscription::class)($d, $plan);
echo "jobs queued after start: ", DB::table('jobs')->count(), " (queues: ", DB::table('jobs')->pluck('queue')->unique()->implode(','), ")\n";
Output from this run (timestamps trimmed):
$ php artisan migrate:fresh --force && php artisan tinker demo.php
jobs queued after start: 2 (queues: mail)
$ php artisan queue:work --queue=mail,default --stop-when-empty
App\Listeners\SendWelcomeEmail ................. RUNNING
App\Listeners\SendWelcomeEmail ................. 2s DONE
App\Listeners\SendWelcomeEmail ................. RUNNING
App\Listeners\SendWelcomeEmail ............ 56.42ms DONE
Then I set every renews_at to an hour ago and ran the command and the worker:
$ php artisan subscriptions:renew
Queued 2 renewal(s).
$ php artisan queue:work --queue=mail,default --stop-when-empty
App\Jobs\RenewSubscriptionJob .................. RUNNING
App\Jobs\RenewSubscriptionJob .................. 1s DONE
App\Jobs\RenewSubscriptionJob .................. RUNNING
App\Jobs\RenewSubscriptionJob ............ 777.56ms DONE
App\Listeners\MarkPastDue ...................... RUNNING
App\Listeners\MarkPastDue ................ 446.04ms DONE
[["[email protected]","active",["paid","paid"]],["[email protected]","past_due",["paid","failed"]]]
The good account has two paid invoices; the declined one is past_due with a failed second invoice. MarkPastDue runs as its own queued job after the renewal job, so for a moment the invoice says failed while the subscription still says active. If your UI shows status, decide whether that gap matters; if it does, set the status inside RenewSubscription and keep the listener for side effects like the email. (The durations are single runs, not benchmarks.)
Pest feature tests for Laravel queues, events and the scheduler
tests/Feature/SubscriptionTest.php:
use App\Events\SubscriptionStarted;
use App\Listeners\SendWelcomeEmail;
use App\Mail\WelcomeMail;
use App\Models\Invoice;
use App\Models\Plan;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
it('requires authentication', function () {
$this->postJson('/subscriptions', ['plan_id' => 1])->assertUnauthorized();
});
it('starts a subscription and writes the first invoice', function () {
Event::fake([SubscriptionStarted::class]);
$user = User::factory()->create();
$plan = Plan::factory()->create(['price_cents' => 1900]);
$this->actingAs($user)->postJson('/subscriptions', ['plan_id' => $plan->id])
->assertCreated()
->assertJsonPath('plan.name', 'Pro');
$sub = $user->subscriptions()->first();
expect($sub->status)->toBe('active')
->and($sub->invoices)->toHaveCount(1)
->and($sub->invoices->first()->amount_cents)->toBe(1900);
Event::assertDispatched(SubscriptionStarted::class);
});
it('rejects inactive or unknown plans', function (int|string $planId) {
$user = User::factory()->create();
Plan::factory()->create(['id' => 7, 'active' => false]);
$this->actingAs($user)->postJson('/subscriptions', ['plan_id' => $planId])
->assertUnprocessable()
->assertJsonValidationErrors('plan_id');
})->with([7, 999, 'abc']);
it('blocks a second active subscription', function () {
$sub = Subscription::factory()->create();
$this->actingAs($sub->user)->postJson('/subscriptions', ['plan_id' => $sub->plan_id])
->assertUnprocessable()
->assertJsonValidationErrors('plan_id');
});
it('lets only the owner view or cancel', function () {
$sub = Subscription::factory()->create();
$stranger = User::factory()->create();
$this->actingAs($stranger)->getJson("/subscriptions/{$sub->id}")->assertForbidden();
$this->actingAs($stranger)->deleteJson("/subscriptions/{$sub->id}")->assertForbidden();
$this->actingAs($sub->user)->deleteJson("/subscriptions/{$sub->id}")->assertOk();
expect($sub->fresh())->status->toBe('cancelled')->renews_at->toBeNull();
// already cancelled: the policy now says no
$this->actingAs($sub->user)->deleteJson("/subscriptions/{$sub->id}")->assertForbidden();
});
it('sends the welcome mail from the queued listener', function () {
Mail::fake();
$user = User::factory()->create();
$plan = Plan::factory()->create();
$this->actingAs($user)->postJson('/subscriptions', ['plan_id' => $plan->id])->assertCreated();
Mail::assertSent(WelcomeMail::class, fn ($m) => $m->hasTo($user->email));
});
it('registers the listener on the mail queue with retries', function () {
$l = new SendWelcomeEmail;
expect($l->queue)->toBe('mail')->and($l->tries)->toBe(3)->and($l->backoff())->toBe([10, 60]);
});
it('does not dispatch the event when the transaction rolls back', function () {
Event::fake([SubscriptionStarted::class]);
$user = User::factory()->create();
$plan = Plan::factory()->create();
try {
DB::transaction(function () use ($user, $plan) {
app(App\Actions\StartSubscription::class)($user, $plan);
throw new RuntimeException('boom after dispatch');
});
} catch (RuntimeException) {
}
Event::assertNotDispatched(SubscriptionStarted::class);
expect(Subscription::count())->toBe(0);
});
it('refuses a second active subscription even when the form request check is bypassed', function () {
$user = User::factory()->create();
$plan = Plan::factory()->create();
$start = app(App\Actions\StartSubscription::class);
$start($user, $plan);
expect(fn () => $start($user, $plan))->toThrow(Illuminate\Validation\ValidationException::class);
expect($user->subscriptions()->count())->toBe(1)
->and(Invoice::count())->toBe(1);
});
tests/Feature/RenewalTest.php:
use App\Actions\RenewSubscription;
use App\Events\RenewalFailed;
use App\Jobs\RenewSubscriptionJob;
use App\Models\Subscription;
use App\Models\User;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
function dueSubscription(array $user = []): Subscription
{
return Subscription::factory()->create([
'user_id' => User::factory()->create($user),
'renews_at' => now()->subHour(),
]);
}
it('queues one job per due subscription and ignores the rest', function () {
Queue::fake();
$due = dueSubscription();
Subscription::factory()->create(); // renews in 30 days
Subscription::factory()->create(['status' => 'cancelled', 'renews_at' => now()->subDay()]);
$this->artisan('subscriptions:renew')->expectsOutput('Queued 1 renewal(s).')->assertSuccessful();
Queue::assertPushed(RenewSubscriptionJob::class, 1);
Queue::assertPushed(RenewSubscriptionJob::class, fn ($j) => $j->subscriptionId === $due->id);
});
it('renews, invoices and moves the date forward', function () {
$sub = dueSubscription();
$old = $sub->renews_at;
$this->artisan('subscriptions:renew')->assertSuccessful(); // sync queue: runs inline
$sub->refresh();
expect($sub->renews_at->equalTo($old->copy()->addDays(30)))->toBeTrue()
->and($sub->invoices()->where('status', 'paid')->count())->toBe(1);
});
it('marks the subscription past_due when the charge is declined', function () {
$sub = dueSubscription(['email' => '[email protected]']);
$this->artisan('subscriptions:renew')->assertSuccessful();
expect($sub->fresh()->status)->toBe('past_due')
->and($sub->invoices()->where('status', 'failed')->count())->toBe(1);
});
it('is safe to run twice in the same hour', function () {
$sub = dueSubscription();
$this->artisan('subscriptions:renew');
$this->artisan('subscriptions:renew');
expect($sub->invoices()->count())->toBe(1);
});
it('is on the schedule hourly, without overlap, on one server', function () {
$event = collect(app(Illuminate\Console\Scheduling\Schedule::class)->events())
->first(fn ($e) => str_contains($e->command, 'subscriptions:renew'));
expect($event)->not->toBeNull()
->and($event->expression)->toBe('0 * * * *')
->and($event->withoutOverlapping)->toBeTrue()
->and($event->onOneServer)->toBeTrue();
});
it('does not dispatch RenewalFailed when the surrounding transaction rolls back', function () {
Event::fake([RenewalFailed::class]);
$sub = dueSubscription(['email' => '[email protected]']);
try {
DB::transaction(function () use ($sub) {
app(RenewSubscription::class)($sub);
throw new RuntimeException('boom after dispatch');
});
} catch (RuntimeException) {
}
Event::assertNotDispatched(RenewalFailed::class);
expect($sub->invoices()->count())->toBe(0);
});
Run result (./vendor/bin/pest, trailing per-test timings trimmed; names ending in … are truncated by Pest):
PASS Tests\Feature\GhostJobTest
✓ it without after-commit, the listener job is queued for a rolled-b…
✓ it with ShouldDispatchAfterCommit, nothing is queued
PASS Tests\Feature\RenewalTest
✓ it queues one job per due subscription and ignores the rest
✓ it renews, invoices and moves the date forward
✓ it marks the subscription past_due when the charge is declined
✓ it is safe to run twice in the same hour
✓ it is on the schedule hourly, without overlap, on one server
✓ it does not dispatch RenewalFailed when the surrounding transaction…
PASS Tests\Feature\SubscriptionTest
✓ it requires authentication
✓ it starts a subscription and writes the first invoice
✓ it rejects inactive or unknown plans with (7)
✓ it rejects inactive or unknown plans with (999)
✓ it rejects inactive or unknown plans with ('abc')
✓ it blocks a second active subscription
✓ it lets only the owner view or cancel
✓ it sends the welcome mail from the queued listener
✓ it registers the listener on the mail queue with retries
✓ it does not dispatch the event when the transaction rolls back
✓ it refuses a second active subscription even when the form request…
Tests: 19 passed (56 assertions)
Duration: 25.70s
Pick the right fake for the question
| You want to prove | Use | Example from this app |
|---|---|---|
| The right job was queued, with the right payload, and nothing else | Queue::fake() + assertPushed |
The command pushes exactly 1 job, for the due id |
| An event was or wasn't fired | Event::fake([X::class]) |
Both rollback tests |
| The side effect actually happens | Leave the queue sync, fake only the outbound (Mail::fake()) |
Welcome email reaches the right address |
| The schedule is configured | Read Schedule::events() |
Expression is 0 * * * *, overlap and one-server flags set |
Prove the suite can fail
A passing suite only matters if it fails when the code is wrong. I made three deliberate breaks by hand, ran the whole suite after each, and restored the file (a final run gave 19 passed (56 assertions)). Failure lines are copied from Pest's output, filtered to the failure and summary lines:
# 1. removed "implements ShouldDispatchAfterCommit" from RenewalFailed
FAILED Tests\Feature\RenewalTest > it does not dispatch RenewalFailed wh…
Failed asserting that actual size 1 matches expected size 0.
Tests: 1 failed, 18 passed (55 assertions)
# 2. removed the same interface from SubscriptionStarted
FAILED Tests\Feature\GhostJobTest > it with ShouldDispatchAfterCommit, n…
Failed asserting that an array is empty.
FAILED Tests\Feature\SubscriptionTest > it does not dispatch the event w…
Failed asserting that actual size 1 matches expected size 0.
Tests: 2 failed, 17 passed (55 assertions)
# 3. deleted Gate::authorize('cancel', ...) from destroy()
FAILED Tests\Feature\SubscriptionTest > it lets only the owner view or c…
Expected response status code [403] but received 200.
Tests: 1 failed, 18 passed (52 assertions)
Pest 4.7.8 requires pestphp/pest-plugin-mutate ^4.0.1 (shown by composer show). I did the breaks by hand and did not run the mutation plugin.
What sync hides
The skeleton's phpunit.xml sets QUEUE_CONNECTION=sync and MAIL_MAILER=array, while .env uses the database queue. So under test, listeners and jobs run inline, and the suite doesn't exercise serialization, backoff(), the mail queue name or a worker restarting mid-job. I checked $queue, $tries and backoff() by reading them off the listener, which is weak. The worker run above covers some of it by hand. If those paths matter to you, add a CI job with QUEUE_CONNECTION=database and a queue:work --stop-when-empty smoke test. Everything here ran on SQLite, the database queue driver and the fake gateway; I did not try Redis, SQS, Horizon or a real payment provider.
What to do next
- Move the one operation in your app that touches two tables and fires an event into an action class.
- Decide your after-commit policy today:
'after_commit' => trueon the queue connection, orShouldDispatchAfterCommiton individual events. Then write the rollback test. - For every job that charges, emails or calls an external API, set
$triesdeliberately. - Make every scheduled job re-check state before acting, and treat
withoutOverlapping()andonOneServer()as a second layer. - Break one of your own rules (delete a policy check or an interface) and confirm a test fails.
- Related reading on this site: Part 3 of the Laravel SaaS series covers tenant-aware queues on Horizon, which is the Redis side this post skipped. Part 2 of the REST API series covers form request validation and API feature tests, if you want more on those two pieces.
Keep reading
Complete Solution: Building a Secure E-commerce Website with Stripe and Laravel
28 min · 209 views
AI WorkflowsMulti-Tenant SaaS Architecture with Laravel and React: Battle-Tested Patterns from Production
33 min · 158 views
Comprehensive TutorialsBuilding a Modern SaaS Application with Laravel - Part 3: Production Scaling, Queues & Observability
17 min · 150 views
Bekzod Erkinov
AuthorFounder of NextGenBeing. Software engineer working with Laravel, Python, and cloud infrastructure. Writes about patterns that actually hold up in production. Based in Tashkent, Uzbekistan.
Get the AI-Assisted Developer's Field Guide
The workflow, prompts, and tools I use to ship faster with AI — free when you subscribe. Plus new deep-dives in your inbox. No spam, unsubscribe anytime.
Comments (0)
Please log in to leave a comment.
Log In