The first time a Laravel test suite saved me from a bad release, it wasn't a clever assertion. It was a five-line feature test that simply said: this customer should not be allowed to refund this order. Someone had refactored the policy, the suite went red, the deploy was blocked, and the customer never knew. That's the bar I want every suite to clear — not "we have tests," but "tests catch the things real users would care about."
Most Laravel projects I see have the opposite shape. Hundreds of tests that prove the framework works. Five tests that prove the business rules work. The suite is slow, brittle, and somehow still misses the bugs that matter.
A useful testing strategy in Laravel is mostly about deciding three things up front: what layer each test belongs in, which fakes you commit to, and how the database resets between tests. Get those three right and the rest is mostly typing.
The Pyramid Is Inverted On Purpose
Classical advice says: lots of unit tests, fewer integration tests, very few end-to-end. In Laravel, the most useful suite usually has a different shape — heavy on feature tests, a thin layer of unit tests for pure logic, and almost no browser tests unless you have real Livewire or Inertia flows.
Why feature-heavy? Because Laravel features are mostly compositions of framework pieces. Validation, authorization, Eloquent, queues, and events all touch each other on every meaningful endpoint. A feature test exercises that composition in one go. A pile of unit tests for the same flow gives you false confidence — every part passes, the whole still breaks.
Where unit tests pay rent: pure calculation classes, value objects, formatters, domain rules expressed as plain PHP. Anything that takes inputs and returns a result without touching the database, the network, or the container.
// app/Pricing/DiscountCalculator.php
final class DiscountCalculator
{
public function for(int $cents, string $tier): int
{
return match ($tier) {
'gold' => (int) round($cents * 0.85),
'silver' => (int) round($cents * 0.92),
default => $cents,
};
}
}
// tests/Unit/DiscountCalculatorTest.php
it('applies the gold discount', function () {
expect((new DiscountCalculator())->for(10_000, 'gold'))->toBe(8_500);
});
That test runs in microseconds, never touches the database, and still catches a real regression if someone tweaks the rate.
Pest 3 Is The Default; PHPUnit Still Runs Underneath
Modern Laravel ships Pest 3.x by default; PHPUnit 11.x is the engine underneath. You can write either flavor in the same suite — phpunit.xml is shared, the test discovery is shared, and php artisan test runs both happily.
# the everyday command
php artisan test
# direct pest, faster output, parallel-friendly
vendor/bin/pest --parallel
# just one file or one filter
php artisan test --filter=ApproveOrder
vendor/bin/pest tests/Feature/Orders
Pest's wins are real: less ceremony per test, datasets that read like a table, expectations that read like English. The plugin ecosystem is small but useful — pest-plugin-laravel for the Laravel helpers (actingAs, get, postJson), pest-plugin-arch for architecture rules, pest-plugin-faker for shorter fake() calls, pest-plugin-stressless if you ever want a quick load test from the same suite.
You don't need to migrate an existing PHPUnit suite to Pest. New tests in Pest, old tests stay PHPUnit, both pass under one command. Don't burn a sprint on a flavor war.
Feature Tests Are Where The Real Value Lives
The shape that pays off, repeated across hundreds of tests:
// tests/Feature/Orders/ApproveOrderTest.php
use App\Models\Order;
use App\Models\User;
use App\Events\OrderApproved;
use Illuminate\Support\Facades\Event;
use function Pest\Laravel\actingAs;
it('lets a manager approve a pending order', function () {
Event::fake([OrderApproved::class]);
$manager = User::factory()->manager()->create();
$order = Order::factory()->pending()->for($manager->team)->create();
actingAs($manager)
->postJson(route('orders.approve', $order), ['notes' => 'OK to ship'])
->assertOk()
->assertJsonPath('data.status', 'approved')
->assertJsonStructure(['data' => ['id', 'status', 'approved_at']]);
expect($order->fresh()->approved_by)->toBe($manager->id);
assertDatabaseHas('orders', [
'id' => $order->id,
'status' => 'approved',
]);
Event::assertDispatched(OrderApproved::class);
});
it('blocks customers from approving orders', function () {
$customer = User::factory()->create(['role' => 'customer']);
$order = Order::factory()->pending()->create();
actingAs($customer)
->postJson(route('orders.approve', $order))
->assertForbidden();
assertDatabaseMissing('orders', ['id' => $order->id, 'status' => 'approved']);
});
What this test exercises in one shot: the route, the auth middleware, the form request, the policy, the controller, the action, the database transaction, the event dispatch, and the JSON resource. If any of those breaks, the test fails for a clear reason. The assertions span all four levels you actually care about — HTTP status, JSON shape, database state, side effects.
The helpers that show up in nearly every feature test:
actingAs($user)— authenticate before the request.getJson,postJson,putJson,deleteJson— JSON-encoded requests, JSON responses.assertOk,assertCreated,assertForbidden,assertUnprocessable— readable status checks.assertJsonStructure(['data' => [...]])— the response shape, without pinning every value.assertJsonPath('data.status', 'approved')— one specific field, deep-pathed.assertDatabaseHas,assertDatabaseMissing,assertDatabaseCount— pin the persistence side.
Fakes Are The Quiet Hero
The reason feature tests stay fast and reliable is the suite of Facade::fake() helpers. They swap the real implementation for an in-memory recorder you can assert against. Use them aggressively — anything outside your process should be faked unless the test is explicitly about that integration.
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
use Illuminate\Support\Facades\Notification;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Bus;
beforeEach(function () {
Http::fake([
'api.stripe.com/*' => Http::response(['id' => 'pi_123', 'status' => 'succeeded'], 200),
]);
Mail::fake();
Queue::fake();
Notification::fake();
Storage::fake('public');
Bus::fake([SyncOrderToWarehouseJob::class]);
});
it('charges Stripe and queues the warehouse sync', function () {
// ... arrange + act ...
Http::assertSent(fn ($req) => $req->url() === 'https://api.stripe.com/v1/payment_intents');
Bus::assertDispatched(SyncOrderToWarehouseJob::class);
Mail::assertQueued(OrderConfirmation::class);
});
The upside is that the test runs in the same second whether Stripe is up, down, or rate-limiting. The trap to watch for: faking everything and accidentally testing a script that does nothing real. Always pair a fake with an assertion that the right thing was sent.
RefreshDatabase, Transactions, Or Migrations?
Three traits, three trade-offs:
RefreshDatabase— the default. Wraps each test in a transaction when possible, falls back to migrating fresh. Fast, isolated, hands-off. Use this 95% of the time.DatabaseTransactions— assumes the schema is already migrated. Wraps each test in a transaction. Slightly faster thanRefreshDatabaseon first run, useful if you maintain a pre-seeded test database.DatabaseMigrations— runs full migrations before each test. Slow. Only worth it if you have migration logic that's part of what you're testing.
The trap with RefreshDatabase is testing code that depends on committed transactions — a model's booted hook that fires on commit, a DB::afterCommit callback, anything dispatched on a queue with afterCommit() semantics. Inside the wrapper transaction, those callbacks never fire, and the test passes for the wrong reason. When that bites, swap to DatabaseTruncation (or DatabaseMigrations) for that one test so each run actually commits and gets cleaned up afterwards, or run it against a real connection.
What You Don't Test
You don't write tests proving Laravel's router works, validation rejects empty strings, or find() returns null when the row is missing. The framework has its own suite. Your time is better spent on:
- Permissions. Who can do what on which resource, and what happens when they can't.
- State transitions. Pending to approved, draft to published, trial to active. The rules at the edges.
- Side effects. Did the right event fire, the right mail queue, the right webhook send.
- Idempotency. Hitting the same endpoint twice should not double-charge, double-send, or double-create.
- Failure paths. What happens when the database is down, the API returns 500, the queue worker dies mid-job.
That last one is where the strategy pays off most. A test that proves the system fails gracefully is worth ten that prove it succeeds on the happy path.
A Suite You'll Actually Run
The other quiet measure of a good strategy is whether the team runs the suite without being asked. That comes down to speed and signal. php artisan test --parallel and vendor/bin/pest --parallel cut wall time roughly in half on most machines. A test that takes 30 seconds because it spins up a real Redis is a test that gets skipped before merging.
Keep the feature tests fast by faking external IO, keep the unit tests narrow, and only reach for full integration tests (real database, real Redis, real S3) at the boundaries you absolutely need to validate. Run those nightly or in CI, not on every save.
The suite that pays for itself is the one where running it is cheaper than not running it.




