← Back to blog

What to Test in Laravel

| Laravel

Many developers struggle with testing. Some test every single line of code, including getters, setters, and framework internals. Others let AI generate their tests and call it a day. Both approaches miss the point entirely.

Testing is not about coverage numbers. It is about confidence. The goal is to know that your application works correctly and that you can change code without breaking things.

This post explains what you should actually test in a Laravel application, and what you can safely skip.

Test behavior, not implementation

The most common mistake is testing how something works instead of what it does. When you test implementation details, your tests break every time you refactor, even when the behavior stays the same.

Here is an example. Suppose you have an action that registers a user:

final class RegisterUser
{
    public function handle(string $name, string $email, string $password): User
    {
        return User::create([
            'name' => $name,
            'email' => $email,
            'password' => Hash::make($password),
        ]);
    }
}

A bad test mocks the model and verifies internal calls:

// Don't do this
it('calls User::create with the correct arguments', function () {
    $spy = User::spy();

    app(RegisterUser::class)->handle('Ahmad', 'ahmad@example.com', 'password');

    $spy->shouldHaveReceived('create')
        ->once()
        ->with(Mockery::on(fn (array $data) => $data['name'] === 'Ahmad'));
});

This test is tightly coupled to the implementation. If you refactor to use $user->fill()->save() instead of User::create(), the test breaks even though the behavior is identical.

A better test verifies the outcome:

it('registers a new user', function () {
    $user = app(RegisterUser::class)->handle('Ahmad', 'ahmad@example.com', 'password');

    expect($user)
        ->toBeInstanceOf(User::class)
        ->name->toBe('Ahmad')
        ->email->toBe('ahmad@example.com');

    expect(Hash::check('password', $user->password))->toBeTrue();

    $this->assertDatabaseHas('users', [
        'email' => 'ahmad@example.com',
    ]);
});

This test does not care about how the user was created. It only cares that a user exists with the correct data.

What you should test

1. Your business logic

This is the most important thing to test. Business rules are the reason your application exists. They define what your app does and how it behaves under different conditions.

it('applies a 10% discount for orders above 100', function () {
    $calculator = new PriceCalculator();

    $total = $calculator->calculate(items: $items, subtotal: 150.00);

    expect($total)->toBe(135.00);
});

it('does not apply a discount for orders below 100', function () {
    $calculator = new PriceCalculator();

    $total = $calculator->calculate(items: $items, subtotal: 80.00);

    expect($total)->toBe(80.00);
});

2. Validation rules

Form requests contain critical validation rules that protect your application. Test them.

it('requires an email to create an account', function () {
    $this->postJson('/api/register', [
        'name' => 'Ahmad',
        'password' => 'securepassword',
        'password_confirmation' => 'securepassword',
    ])->assertUnprocessable()
      ->assertJsonValidationErrors(['email']);
});

it('rejects duplicate email addresses', function () {
    User::factory()->create(['email' => 'ahmad@example.com']);

    $this->postJson('/api/register', [
        'name' => 'Ahmad',
        'email' => 'ahmad@example.com',
        'password' => 'securepassword',
        'password_confirmation' => 'securepassword',
    ])->assertUnprocessable()
      ->assertJsonValidationErrors(['email']);
});

3. Authorization and policies

Access control bugs are the most dangerous bugs. Always test who can do what.

it('allows admins to delete users', function () {
    $admin = User::factory()->admin()->create();

    $this->actingAs($admin)
        ->deleteJson("/api/users/{$user->id}")
        ->assertNoContent();
});

it('prevents regular users from deleting other users', function () {
    $user = User::factory()->create();
    $other = User::factory()->create();

    $this->actingAs($user)
        ->deleteJson("/api/users/{$other->id}")
        ->assertForbidden();
});

4. Edge cases and error handling

The happy path is easy. The interesting tests cover what happens when things go wrong.

it('throws an exception when transferring more than the available balance', function () {
    $account = Account::factory()->create(['balance' => 100]);

    expect(fn () => $account->transfer(amount: 150, to: $targetAccount))
        ->toThrow(InsufficientBalanceException::class);
});

it('handles concurrent booking attempts gracefully', function () {
    $seat = Seat::factory()->available()->create();

    // First booking succeeds
    $booking1 = app(BookSeat::class)->handle($seat, $user1);
    expect($booking1->isConfirmed())->toBeTrue();

    // Second booking for the same seat fails
    expect(fn () => app(BookSeat::class)->handle($seat, $user2))
        ->toThrow(SeatAlreadyBookedException::class);
});

5. API responses

If you expose an API, test the structure and status codes. Consumers depend on them.

it('returns a paginated list of posts', function () {
    Post::factory()->count(25)->create();

    $this->getJson('/api/posts')
        ->assertOk()
        ->assertJsonStructure([
            'data' => [
                '*' => ['id', 'title', 'slug', 'published_at'],
            ],
            'meta' => ['current_page', 'last_page', 'per_page', 'total'],
        ])
        ->assertJsonCount(15, 'data');
});

6. Database state changes

When an action modifies the database, verify the change actually happened:

it('soft deletes the project and its tasks', function () {
    $project = Project::factory()->hasTasks(3)->create();

    app(ArchiveProject::class)->handle($project);

    expect($project->fresh()->trashed())->toBeTrue();

    $this->assertSoftDeleted('projects', ['id' => $project->id]);
    $this->assertDatabaseCount('tasks', 3);

    $project->tasks->each(function (Task $task) {
        $this->assertSoftDeleted('tasks', ['id' => $task->id]);
    });
});

7. Jobs, events, and notifications

Verify that jobs are dispatched, events are fired, and notifications are sent. You do not need to test their internal behavior separately, just that they were triggered.

it('dispatches a welcome email after registration', function () {
    Notification::fake();

    $this->postJson('/api/register', [
        'name' => 'Ahmad',
        'email' => 'ahmad@example.com',
        'password' => 'securepassword',
        'password_confirmation' => 'securepassword',
    ])->assertCreated();

    Notification::assertSentTo(
        User::where('email', 'ahmad@example.com')->first(),
        WelcomeNotification::class
    );
});

it('queues an invoice generation job after payment', function () {
    Queue::fake();

    app(ProcessPayment::class)->handle($order);

    Queue::assertPushed(GenerateInvoice::class, function ($job) use ($order) {
        return $job->order->id === $order->id;
    });
});

What you should NOT test

1. Framework code

Do not test that Eloquent saves records, that the router routes, or that Blade renders views. Taylor and the Laravel team already do that. Trust the framework.

// Don't do this
it('saves a model to the database', function () {
    $user = new User(['name' => 'Ahmad']);
    $user->save();

    $this->assertDatabaseHas('users', ['name' => 'Ahmad']);
});

This test tells you nothing about your application. It only confirms that Eloquent works.

2. Getters and setters

If a method simply returns or sets a property, there is nothing to test.

// Don't do this
it('returns the user name', function () {
    $user = new User(['name' => 'Ahmad']);
    expect($user->name)->toBe('Ahmad');
});

3. Configuration values

Do not test that a config value equals what you just set in the config file.

// Don't do this
it('has the correct app name', function () {
    expect(config('app.name'))->toBe('My App');
});

4. Constructor assignments

Do not verify that a constructor stores a dependency. That is a language feature, not your application.

// Don't do this
it('assigns the repository', function () {
    $repo = new UserRepository();
    $service = new UserService($repo);

    expect($service->repository)->toBe($repo);
});

5. Third-party package internals

If a package provides a feature, do not test the package itself. Test your code that uses the package.

The AI testing problem

AI-generated tests often suffer from two issues:

1. Testing the implementation, not the behavior

AI tools tend to generate tests that mirror the code structure rather than testing outcomes. They see a method calling Cache::put() and write a test that asserts Cache::put() was called. That is not useful. What matters is whether the cached value is available on subsequent requests.

2. Testing too many things at once

A single AI-generated test might assert the response status, the database state, the dispatched jobs, the sent notifications, and the logged messages. When it fails, you have no idea which behavior actually broke.

The fix is simple: review every AI-generated test and ask yourself two questions:

  • Does this test break when I change the behavior? If yes, it is a good test.
  • Does this test break when I refactor without changing behavior? If yes, rewrite it.

Practical guidelines

Here is a quick reference for what to test and what to skip:

Always test:

  • Business rules and domain logic
  • Validation rules on form requests
  • Authorization (policies and gates)
  • Edge cases and error handling
  • API response structure and status codes
  • Database state changes from actions
  • That jobs, events, and notifications are dispatched

Skip testing:

  • Framework internals (Eloquent, routing, Blade)
  • Simple getters, setters, and constructors
  • Configuration values
  • Third-party package behavior
  • Private methods (test them through the public API)

Summary

Testing is about proving your application works, not about proving PHP or Laravel works. Focus on behavior and outcomes. Test what your code does, not how it does it.

Write fewer, better tests. Every test should give you confidence that a specific behavior works. If a test does not increase your confidence, delete it.

And if you use AI to generate tests, treat those tests as a first draft. Review them, question them, and rewrite the ones that test implementation details instead of behavior.

Share