Testing, frameworks and deployment
PHPUnit and Pest, unit versus feature tests, a tour of Laravel and Symfony routing, and shipping with OPcache, PHP-FPM and zero-downtime releases.
Unit and feature tests
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
final class SluggerTest extends TestCase
{
public function test_it_slugifies_a_title(): void
{
self::assertSame("hello-world", (new Slugger())->slug("Hello World"));
}
public function test_it_rejects_an_empty_string(): void
{
$this->expectException(InvalidArgumentException::class);
(new Slugger())->slug(" ");
}
}<?php
// Pest: the same two tests, with closures and expectations
it("slugifies a title", function () {
expect((new Slugger())->slug("Hello World"))->toBe("hello-world");
});
it("rejects an empty string", function () {
(new Slugger())->slug(" ");
})->throws(InvalidArgumentException::class);- A unit test exercises one class with its collaborators replaced by test doubles; it is fast and says exactly what broke.
- A feature test goes through the router and asserts the response:
$this->get("/health")->assertOk();. - Test the behaviour you promised, not the private method you happen to have written. Refactoring should not break a test.
- One assertion of meaning per test reads better than five unrelated ones, and failures point at the cause rather than the symptom.
What a framework adds
<?php
// Laravel: routes, container, Eloquent
Route::get("/invoices/{invoice}", [InvoiceController::class, "show"]);
final class InvoiceController
{
public function show(Invoice $invoice): JsonResponse
{
return response()->json($invoice->load("lines"));
}
}
// Symfony: attribute routing with dependency injection
#[Route("/invoices/{id}", methods: ["GET"])]
public function show(int $id, InvoiceRepository $repo): Response
{
return $this->json($repo->findOrFail($id));
}- Both frameworks give you the same three things: a front controller, a way to map a URL to a handler, and a container that builds objects for you.
- Model binding converts a route parameter into an object and returns 404 when it does not exist, which replaces a recurring block of boilerplate.
- Migrations belong in version control with the code: a schema change and the query that needs it ship together.
Shipping it
| Setting | Production value and why |
|---|---|
opcache.enable | 1 - compile once, reuse the bytecode |
opcache.validate_timestamps | 0 - no stat calls per request, but a reload is required after deploy |
opcache.memory_consumption | 128-256 MB, sized from opcache_get_status() |
opcache.preload | Warms framework classes at start-up |
pm (PHP-FPM) | dynamic for steady traffic, ondemand for spiky low-volume sites |
display_errors | 0, with logging configured instead |
# a release directory per build, switched with a symlink
composer install --no-dev --optimize-autoloader
php bin/console cache:warmup # Laravel: php artisan optimize
ln -sfn /srv/releases/2026-09-18-1 /srv/current
systemctl reload php8.5-fpm # new opcache, zero dropped requests⚠️
With
validate_timestamps=0 the opcode cache never notices changed files - a deploy that forgets to reload the workers keeps serving the previous code while the files on disk say otherwise. Make the reload part of the deploy script, not a remembered step.FAQ
Where should I start with tests?
One feature test per route and one unit test per pure function or value object. That combination catches most regressions and stays fast enough to run before every commit.
How do I scale a PHP application?
First profile: database queries, then OPcache and PHP-FPM sizing. PHP is usually waiting on I/O, so adding workers helps only until the database or the connection limit becomes the bottleneck.
Related
Composer, dependencies and coding standards Databases with PDO and prepared statements
Last refreshed 2026-09-18.