Faking the Bus Without Breaking Your Listeners
When you call Bus::fake() in a Laravel test on PHP 8.3, Laravel replaces the underlying dispatcher bound in the service container with Illuminate\Support\Testing\Fakes\BusFake. Every call to dispatch(), dispatchSync(), or dispatchAfterResponse() gets recorded in memory instead of hitting Redis, SQS, or your database queue table.
The biggest mistake developers make is passing no arguments to Bus::fake() when they only care about one specific job. Calling Bus::fake() without arguments stops all jobs from running, including internal framework jobs or synchronous jobs dispatched inside event listeners that your test relies on.
If your application fires a UserRegistered event, and a listener synchronously runs an audit log job while queuing a SendWelcomeEmailJob, faking the whole bus prevents the audit log from running. Pass the specific job class to Bus::fake() to fake only what you need:
namespace Tests\Feature;
use App\Events\UserRegistered;
use App\Jobs\SendWelcomeEmailJob;
use App\Jobs\AuditLogJob;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Tests\TestCase;
class RegistrationTest extends TestCase
{
use RefreshDatabase;
public function test_user_registration_queues_welcome_email(): void
{
Bus::fake([
SendWelcomeEmailJob::class,
]);
$user = User::factory()->create([
'email' => 'alex@example.com',
]);
Event::dispatch(new UserRegistered($user));
Bus::assertDispatched(SendWelcomeEmailJob::class, function (SendWelcomeEmailJob $job) use ($user) {
return $job->user->id === $user->id
&& $job->user->email === 'alex@example.com';
});
Bus::assertNotDispatched(AuditLogJob::class);
}
}Testing Job Payloads with Precision
Asserting that a job was dispatched isn't enough. You need to verify that the job carries the exact payload your handler expects. If your job constructor accepts an Eloquent model, remember that Laravel serializes models using the SerializesModels trait. It stores the class name and primary key, not the full model state at dispatch time.
If you modify a model after dispatching a job, the callback in Bus::assertDispatched() evaluates the model in its current state unless you explicitly test primitive properties or inspect raw job parameters.
Check public properties directly on the job instance inside the callback closure. Always return a boolean value from the truth test function:
Bus::assertDispatched(ProcessOrderPayment::class, function (ProcessOrderPayment $job) use ($order) {
return $job->orderId === $order->id
&& $job->amountInCents === 4999
&& $job->currency === 'USD';
});If the closure returns false, the assertion fails and PHPUnit prints a clear diff showing that the job was dispatched, but didn't match your callback constraints.
Checking Chains and Batches
When testing complex workflows involving job chains or batches, simple dispatch assertions fall short. Laravel provides dedicated methods on BusFake like assertChained() and assertBatched().
Here is how you test a job chain dispatched from an API endpoint:
Bus::fake();
$response = $this->postJson('/api/reports/generate', [
'type' => 'monthly',
]);
$response->assertStatus(202);
Bus::assertChained([
GenerateReportJob::class,
CompressReportJob::class,
NotifyUserJob::class,
]);If you need to verify the payload inside nested chained jobs, pass closures inside the chain array:
Bus::assertChained([
new GenerateReportJob('monthly'),
function (CompressReportJob $job) {
return $job->format === 'zip';
},
NotifyUserJob::class,
]);The Event::fake Trap
Combining Event::fake() and Bus::fake() causes massive confusion if you don't understand how Laravel handles queued event listeners. When an event listener implements ShouldQueue, Laravel doesn't dispatch a normal job through the Bus facade. Instead, it dispatches an Illuminate\Events\CallQueuedListener job via the queue connection.
If you call Event::fake() at the start of your test, Laravel prevents all event listeners from firing. That means your queued listener will never execute, and Bus::assertDispatched() will fail because no job was ever created.
When testing that an event triggers a queued job, fake the Bus, but do not fake the Event facade. Let the event fire naturally so the event dispatcher pushes the listener job onto your fake bus:
namespace Tests\Feature;
use App\Events\OrderPlaced;
use App\Listeners\SendOrderConfirmationNotification;
use App\Models\Order;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Bus;
use Tests\TestCase;
class OrderPipelineTest extends TestCase
{
use RefreshDatabase;
public function test_order_placed_event_queues_notification_listener(): void
{
Bus::fake();
// Leaving Event::fake() out allows OrderPlaced listeners to run
$order = Order::factory()->create();
event(new OrderPlaced($order));
Bus::assertDispatched(SendOrderConfirmationNotification::class, function ($job) use ($order) {
return $job->order->id === $order->id;
});
}
}If you only want to fake specific events while keeping queued listeners working for others, pass the event classes to Event::fake([OrderCancelled::class]). Leaving the target event out of the array lets its queued listeners fire correctly.
Testing Real Job Execution
Faking tells you if a job was sent, but it tells you nothing about whether the job's handle() method actually works. To test job logic, execute the job directly in your test without invoking queue worker overhead.
Use dispatchSync() or call the handle() method directly on an instantiated job class. When calling handle() manually, pass mock dependencies into the method if the job relies on type-hinted service container resolution:
public function test_payment_job_processes_charge_successfully(): void
{
$stripeMock = $this->createMock(PaymentGatewayContract::class);
$stripeMock->expects($this->once())
->method('charge')
->with(1000)
->willReturn('ch_test_12345');
$job = new ProcessPaymentJob(1000);
// Inject dependencies directly into handle()
$job->handle($stripeMock);
$this->assertDatabaseHas('payments', [
'transaction_id' => 'ch_test_12345',
'status' => 'completed',
]);
}If your job uses retry policies or backoff settings, assert those class properties directly in unit tests:
public function test_job_has_correct_retry_configuration(): void
{
$job = new ProcessPaymentJob(1000);
$this->assertSame(3, $job->tries);
$this->assertSame(60, $job->backoff);
}Testing queues effectively in Laravel comes down to knowing when to fake boundaries and when to let framework wiring operate. Fake specific job classes with Bus::fake([Job::class]), keep Event::fake() away from queued listeners, and execute handle() directly when testing domain logic.












