Testing & Performance
Feature tests, factories, caching and query optimisation.
Two things separate a demo from a production application: confidence that it works and speed under real load. Laravel has first-class tooling for both. On the testing side there's an expressive HTTP testing layer, model factories and fakes for every side effect. On the performance side there's caching, queue offloading and framework-level optimisation commands.
This final lesson tests the Projects & Tasks app end to end and then makes it fast.
The testing pyramid
In most Laravel apps, feature tests give the best return. They send a real HTTP request through routing, middleware, validation, policies and Eloquent into a real (test) database, then assert on the response and the resulting state. They catch the bugs users would hit, and they survive refactors because they don't care how the controller works internally.
Setting up
Laravel supports both PHPUnit and Pest. Pest is a nicer syntax layered on top of PHPUnit. This project uses PHPUnit, so the examples below do too, with a Pest equivalent where it helps. Tests run against an isolated in-memory SQLite database configured in phpunit.xml:
phpunit.xml (excerpt)
<php>
<env name="APP_ENV" value="testing" force="true"/>
<env name="CACHE_STORE" value="array" force="true"/>
<env name="DB_CONNECTION" value="sqlite" force="true"/>
<env name="DB_DATABASE" value=":memory:" force="true"/>
<env name="QUEUE_CONNECTION" value="sync" force="true"/>
<!-- $_SERVER wins over $_ENV in Laravel, and Docker sets real DB/cache vars: force test values here too -->
<server name="DB_CONNECTION" value="sqlite" force="true"/>
<server name="DB_DATABASE" value=":memory:" force="true"/>
<server name="APP_CONFIG_CACHE" value="bootstrap/cache/config.testing.php" force="true"/>
</php>
The real file forces CACHE_STORE, QUEUE_CONNECTION, SESSION_DRIVER and MAIL_MAILER the same way.
A real lesson from building this app: tests can wipe your dev database. RefreshDatabase drops and re-creates every table on whatever connection the tests resolve to. In Docker, DB_CONNECTION=mysql and DB_DATABASE=laravel_cms are real container environment variables. Laravel reads $_SERVER before $_ENV, and a plain <env> entry doesn't override a variable that's already set, so the test run quietly used the development MySQL database and emptied it. Two fixes now protect this repo:
phpunit.xmlforces the test values withforce="true"on both<env>and<server>entries, and points config caching at a separateconfig.testing.php, so a cached dev config can't leak in either.- A guard in the base test case refuses to run database traits against anything but in-memory SQLite:
tests/TestCase.php
abstract class TestCase extends BaseTestCase
{
/**
* Refuse to run database traits (RefreshDatabase wipes every table)
* against anything but the in-memory SQLite test database.
*/
protected function setUpTraits()
{
$connection = config('database.default');
$database = config("database.connections.{$connection}.database");
if ($connection !== 'sqlite' || $database !== ':memory:') {
throw new RuntimeException("Tests must use in-memory SQLite, got [{$connection}:{$database}]. Check phpunit.xml.");
}
return parent::setUpTraits();
}
}
Running the demo suite inside the app container:
docker exec -u 1000:1000 -e HOME=/tmp laravel-cms-app php artisan test --compact tests/Feature/Demo
..................
Tests: 18 passed (120 assertions)
Duration: 0.64s
Other useful invocations:
php artisan make:test TaskManagementTest # feature test
php artisan make:test TaskPriorityTest --unit
php artisan test --filter=test_demo_api_is_rate_limited
php artisan test --parallel # split across CPU cores
Factories
Factories create realistic models in one line. They're the foundation of readable tests. This is the real factory (PHPDoc trimmed):
database/factories/TaskFactory.php
namespace Database\Factories;
use App\Enums\TaskPriority;
use App\Models\Project;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
class TaskFactory extends Factory
{
public function definition(): array
{
return [
'project_id' => Project::factory(), // creates a parent automatically
'user_id' => User::factory(),
'title' => fake()->sentence(4),
'description' => fake()->optional()->paragraph(),
'priority' => fake()->randomElement(TaskPriority::cases()),
'due_on' => fake()->optional()->dateTimeBetween('now', '+1 month'),
'completed_at' => null,
];
}
public function completed(): static
{
return $this->state(fn (array $attributes) => [
'completed_at' => now(),
]);
}
public function highPriority(): static
{
return $this->state(fn (array $attributes) => [
'priority' => TaskPriority::High,
]);
}
}
States compose, and relationships can be built inline:
Task::factory()->highPriority()->completed()->create();
Project::factory()
->count(3)
->has(Task::factory()->count(4))
->create(); // 3 projects × 4 tasks, each with its own assignee
Task::factory()->count(2)->for($project)->create();
The demo data on these pages comes from TutorialDemoSeeder, which is idempotent (it's safe to run on every deploy). It uses fixed data rather than factories, because Faker is a dev dependency and isn't installed in the production image. A test proves it's idempotent:
tests/Feature/Demo/TutorialDemoSeederTest.php
public function test_seeder_is_idempotent(): void
{
$this->seed(TutorialDemoSeeder::class);
$this->seed(TutorialDemoSeeder::class);
$this->assertSame(3, Project::count());
$this->assertSame(20, Task::count());
$this->assertSame(5, Tag::count());
$this->assertSame(4, User::count());
$this->assertTrue(Project::where('slug', 'website-relaunch')->exists());
}
Feature tests
Every live demo in this tutorial track is backed by a feature test in tests/Feature/Demo/. These excerpts from DemoApiTest show the core moves: arrange data with factories, make an HTTP request, assert on the response:
tests/Feature/Demo/DemoApiTest.php (5 of its 11 tests)
namespace Tests\Feature\Demo;
use App\Enums\TaskPriority;
use App\Models\Project;
use App\Models\Task;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class DemoApiTest extends TestCase
{
use RefreshDatabase;
public function test_projects_index_includes_owner_and_task_counts(): void
{
$project = Project::factory()->create();
Task::factory()->count(2)->for($project)->create();
Task::factory()->completed()->for($project)->create();
$this->getJson(route('api.demo.projects.index'))
->assertOk()
->assertJsonPath('data.0.slug', $project->slug)
->assertJsonPath('data.0.owner', $project->owner->name)
->assertJsonPath('data.0.tasks_count', 3)
->assertJsonPath('data.0.open_tasks_count', 2);
}
public function test_tasks_can_be_filtered_by_priority_and_status(): void
{
$match = Task::factory()->highPriority()->create();
Task::factory()->highPriority()->completed()->create();
Task::factory()->create(['priority' => TaskPriority::Low]);
$this->getJson(route('api.demo.tasks.index', ['priority' => 'high', 'status' => 'open']))
->assertOk()
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.id', $match->id)
->assertJsonPath('meta.total', 1);
}
public function test_validate_endpoint_returns_json_errors_even_for_plain_form_posts(): void
{
$this->post(route('api.demo.tasks.validate'), ['title' => 'x', 'priority' => 'urgent'])
->assertUnprocessable()
->assertJsonValidationErrors(['project_id', 'title', 'priority']);
}
public function test_validate_endpoint_accepts_valid_tasks_without_saving_them(): void
{
$project = Project::factory()->create();
$this->postJson(route('api.demo.tasks.validate'), [
'project_id' => $project->id,
'title' => 'Write the release notes',
'priority' => 'medium',
'due_on' => now()->addWeek()->toDateString(),
])
->assertOk()
->assertJsonPath('valid', true)
->assertJsonPath('data.title', 'Write the release notes');
$this->assertDatabaseCount('tasks', 0);
}
public function test_demo_api_is_rate_limited(): void
{
for ($i = 0; $i < 120; $i++) {
$this->getJson(route('api.demo.projects.index'))->assertOk();
}
$this->getJson(route('api.demo.projects.index'))->assertTooManyRequests();
}
}
RefreshDatabase migrates the schema once, then wraps each test in a transaction that's rolled back. Every test starts clean, and it's fast.
For pages behind a login, actingAs() signs a user in for the request. Illustrative — not in this app:
$this->actingAs($owner)
->post(route('projects.tasks.store', $project), ['title' => 'Write release notes', 'priority' => 'high'])
->assertRedirect(route('projects.show', $project));
The same API test in Pest, for comparison. Illustrative — this repo uses PHPUnit:
use App\Models\{Project, Task};
it('lists projects with task counts', function () {
$project = Project::factory()->create();
Task::factory()->count(2)->for($project)->create();
$this->getJson(route('api.demo.projects.index'))
->assertOk()
->assertJsonPath('data.0.tasks_count', 2);
});
Testing side effects. To test a controller that sends an email and dispatches a job, don't let it really send anything. Fake the side effect, run the code, then assert what would have happened. It's fast, deterministic and tests your intent, not the mail server.
Try it: the assertions against the live API
The runner below sends real requests to this site's /api/demo endpoints and checks each response the way the PHPUnit assertions above do. Tick the box to break one expectation and see how a failure reads.
Faking side effects
tests/Feature/Demo/TaskCompletionTest.php checks the event flow from the queues lesson without a worker running:
tests/Feature/Demo/TaskCompletionTest.php
use App\Events\TaskCompleted;
use App\Listeners\LogTaskCompletion;
use Illuminate\Events\CallQueuedListener;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Queue;
public function test_completing_a_task_dispatches_the_event_once(): void
{
Event::fake([TaskCompleted::class]);
$task = Task::factory()->create();
$task->markComplete();
$task->markComplete(); // second call must be a no-op
$this->assertNotNull($task->fresh()->completed_at);
Event::assertDispatchedTimes(TaskCompleted::class, 1);
Event::assertDispatched(TaskCompleted::class, fn (TaskCompleted $event) => $event->task->is($task));
}
public function test_listener_is_registered_for_the_event(): void
{
Event::fake();
Event::assertListening(TaskCompleted::class, LogTaskCompletion::class);
}
public function test_listener_runs_on_the_queue(): void
{
Queue::fake();
Task::factory()->create()->markComplete();
// Queued listeners are wrapped in a CallQueuedListener job
Queue::assertPushed(CallQueuedListener::class, fn (CallQueuedListener $job) => $job->class === LogTaskCompletion::class);
}
The other fakes work the same way. Illustrative — not in this app:
Notification::fake(); Notification::assertSentTo($user, TaskAssignedNotification::class);
Mail::fake(); Mail::assertQueued(WeeklyDigest::class);
Storage::fake('s3'); Storage::disk('s3')->assertExists('exports/project-7.csv');
Bus::fake(); Bus::assertChained([ExportTasksToCsv::class, UploadExportToS3::class]);
Faking HTTP: testing this repo's config service
This CMS loads its site configuration from a central API in app/Services/SiteConfigService.php. It caches the result for five minutes with Cache::remember(), uses a 5-second Http::timeout(), and falls back to defaults if the API is down. That fallback is exactly the kind of behaviour worth pinning down. Illustrative — these tests aren't in the repo yet:
use App\Services\SiteConfigService;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
public function test_site_config_falls_back_to_defaults_when_api_is_down(): void
{
config(['cms.site_id' => 7, 'cms.config_api_url' => 'https://cms.example']);
Http::fake([
'cms.example/*' => fn () => throw new ConnectionException('timed out'),
]);
$config = app(SiteConfigService::class)->get();
$this->assertArrayHasKey('site_title', $config); // defaults, no exception
Http::assertSentCount(1);
}
public function test_site_config_is_cached_between_calls(): void
{
config(['cms.site_id' => 7, 'cms.config_api_url' => 'https://cms.example']);
Http::fake(['cms.example/*' => Http::response(['site_title' => 'Demo'])]);
$service = app(SiteConfigService::class);
$service->get();
$service->get();
// One config fetch (+ one heartbeat). The second get() came from cache.
Http::assertSent(fn ($request) => str_contains($request->url(), '/config/'));
$this->assertSame('Demo', $service->get()['site_title']);
}
Add Http::preventStrayRequests() in your base TestCase::setUp() so any un-faked outbound call fails loudly instead of quietly hitting a real API.
Performance: measure first
Never optimise blind. Find out where the time goes:
- Laravel Debugbar or Telescope locally: queries per request, duplicate queries, slow queries.
DB::listen()orModel::preventLazyLoading()to surface N+1 problems (see Eloquent Relationships).php artisan db:monitorand your database's slow query log in production.- APM tools (Sentry, New Relic) or Laravel Nightwatch for real-user timings.
You can even assert performance in a test. This one, from tests/Feature/Demo/DemoApiTest.php, locks in the N+1 fix behind the relationships lesson's live demo:
public function test_eager_loading_runs_fewer_queries_than_lazy_loading(): void
{
Project::factory()
->count(3)
->has(Task::factory()->count(4))
->create();
// 1 projects query + 3 task queries + 12 assignee queries
$this->getJson(route('api.demo.query-count', ['eager' => 0]))
->assertOk()
->assertJsonPath('query_count', 16);
// projects, tasks, users: one query each
$this->getJson(route('api.demo.query-count', ['eager' => 1]))
->assertOk()
->assertJsonPath('query_count', 3);
}
If someone later removes the with('tasks.assignee'), CI fails instead of production slowing down.
Database optimisation
Indexes
An index turns a full table scan into a lookup. Index the columns you filter, join and sort on:
database/migrations/2026_10_18_151928_create_tasks_table.php (excerpt)
// From this app's tasks migration: matches "open tasks for a project"
// WHERE project_id = ? AND completed_at IS NULL (Task::open() scoped to a project)
$table->index(['project_id', 'completed_at']);
EXPLAIN SELECT * FROM tasks WHERE project_id = ? AND completed_at IS NULL;
-- key: tasks_project_id_completed_at_index, type: ref ✓
-- (without it, on a big table: type: ALL, a full scan) ✗
Column order matters. An index on (project_id, completed_at) also serves queries on project_id alone, but not queries on completed_at alone.
Fetch less
// Only the columns you need
Task::select(['id', 'title', 'priority'])->where('project_id', $id)->get();
// Let the database aggregate
Project::withCount(['tasks', 'tasks as open_tasks_count' => fn ($q) => $q->open()])->get();
// Huge exports: stream instead of loading 500k models into memory
Task::completed()->lazyById(1000)->each(fn ($task) => $csv->insertOne($task->toCsvRow()));
// Bulk writes in one query instead of N
Task::whereIn('id', $ids)->update(['completed_at' => now()]); // note: skips model events
Task::upsert($rows, uniqueBy: ['external_id'], update: ['title', 'priority']);
Caching and deploy optimisation
Once queries are lean, the next win is not running them at all. Caching Strategies covers Cache::remember(), invalidation and stampede protection against a real cached endpoint, plus the framework caches (config:cache, route:cache, view:cache) this site's deploy rebuilds on every release. In production, also keep OPcache on (it is on this site's server). Laravel Octane goes further by keeping the app booted between requests, at the cost of watching for state leaking between requests.
Key takeaways
- Feature tests with
RefreshDatabase, factories andactingAs()give the most confidence per line of test code. - Fake side effects (
Queue::fake(),Http::fake(),Mail::fake()) and assert intent. UseHttp::preventStrayRequests()in tests. - Measure before optimising. Hunt N+1 queries, add composite indexes that match real queries, and select and aggregate in SQL.
Cache::remember()plus deliberate invalidation. UseCache::flexible()or locks to prevent stampedes, and a shared store across servers.- Ship with
composer --no-dev -o,php artisan optimizeand OPcache. Read settings throughconfig(), neverenv(), once config is cached.