Free Handbook · Every example compiled & verified

PHP + Tools

Nine mini-labs on PHP as it is used at work: Composer and PSR-4, Laravel and its service container, Symfony, PDO with MySQL, Redis, PHPUnit, Docker and CI.

0 / 148 lessons🔥 0 day streak
ShareXLinkedIn

Module 12 · what you'll be able to do

  • Write a composer.json with PSR-4 autoloading and explain what vendor/autoload.php does
  • Build a Laravel route, controller and Eloquent model, and bind interfaces in the service container
  • Read a Symfony controller and connect to MySQL or PostgreSQL with PDO and transactions
  • Cache slow reads in Redis with the cache-aside pattern, and test code with PHPUnit
  • Run PHP-FPM behind Nginx with Docker Compose, and gate every push with PHPStan and PHPUnit in GitHub Actions
01

The PHP toolchain at a glance

Every program so far has been one main.php run with php main.php. A real PHP application is hundreds of classes, dozens of libraries, a database, a cache, a web server and a test suite. A job listing that says "PHP" nearly always means PHP plus Composer, a framework (usually Laravel or Symfony), MySQL or PostgreSQL, and Docker. These labs show the smallest real version of each. Most of the code uses libraries or servers, so it is shown as static snippets; where the idea can run on plain PHP (autoloading, a service container, a transaction, cache-aside) there is a verified example as well.

ToolJobYou meet it when
ComposerInstall libraries, autoload classes, run scriptsDay one of any PHP job
Laravel / SymfonyRouting, controllers, ORM, validation, dependency injectionAlmost every PHP web role
PDO + MySQL / PostgreSQLStore and query dataAny app that keeps data
RedisCache, sessions, queues, rate limitsAs soon as a page gets slow
PHPUnit (and Pest)Automated testsEvery pull request
Docker + Nginx + PHP-FPMRun the same stack on every machine and serverLocal setup and deployment
Git + GitHub Actions + PHPStanVersion control, CI, static analysisEvery day
02

PHP + Composer: packages and PSR-4 autoloading

Composer is PHP's package manager. composer.json lists what your project needs; composer install downloads it from Packagist into vendor/ and writes composer.lock with the exact versions it chose. Composer also generates vendor/autoload.php: require that one file and every class — yours and your libraries' — loads on first use, with no require per file.

PHP + Composer

A composer.json with PSR-4 autoloading, dev tools and scripts

PSR-4 is the naming rule that makes autoloading work: a namespace prefix maps to a folder, and the rest of the class name is the path. With the mapping below, App\Billing\Invoice lives in src/Billing/Invoice.php. require-dev packages (tests, static analysis) are installed locally and in CI but skipped in production with --no-dev. Version constraints like ^3.0 mean "3.0 or newer, but below 4.0".

json
{
  "name": "acme/shop",
  "type": "project",
  "require": {
    "php": "^8.3",
    "ext-pdo": "*",
    "monolog/monolog": "^3.0"
  },
  "require-dev": {
    "phpunit/phpunit": "^12.0",
    "phpstan/phpstan": "^2.0"
  },
  "autoload": {
    "psr-4": { "App\\": "src/" }
  },
  "autoload-dev": {
    "psr-4": { "App\\Tests\\": "tests/" }
  },
  "scripts": {
    "test": "phpunit",
    "analyse": "phpstan analyse src tests --level 6"
  },
  "config": { "sort-packages": true }
}
bash
composer init                       # interactive: creates composer.json
composer require monolog/monolog    # add a library (updates json + lock)
composer require --dev phpunit/phpunit
composer install                    # install exactly what composer.lock says
composer update monolog/monolog     # move one package to a newer allowed version
composer dump-autoload              # regenerate the autoloader after editing "autoload"
composer run test                   # run a script from "scripts"
composer install --no-dev --optimize-autoloader   # production

There is no magic inside vendor/autoload.php. PHP calls every function registered with spl_autoload_register the first time an unknown class is used, and the function turns the class name into a file path and requires it. This program sets up a one-file project and does exactly what Composer's PSR-4 loader does.

phpmain.php
<?php
// Set up a tiny project on disk: src/Billing/Invoice.php
$base = sys_get_temp_dir() . '/psr4-demo-' . getmypid();
mkdir("$base/src/Billing", 0777, true);
file_put_contents("$base/src/Billing/Invoice.php", '<?php
namespace App\Billing;

final class Invoice
{
    public function __construct(public readonly int $cents) {}
    public function total(): string { return number_format($this->cents / 100, 2); }
}
');

// PSR-4: "App\" maps to src/, the rest of the class name is the path
spl_autoload_register(function (string $class) use ($base): void {
    $prefix = 'App\\';
    if (!str_starts_with($class, $prefix)) {
        return;                                    // not ours: let another loader try
    }
    $path = str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
    echo "autoload $class -> src/$path\n";
    if (is_file("$base/src/$path")) {
        require "$base/src/$path";
    }
});

$invoice = new App\Billing\Invoice(129900);         // first use: loader runs
echo $invoice->total(), "\n";
$again = new App\Billing\Invoice(500);              // already loaded: no loader call
echo $again->total(), "\n";
var_dump(class_exists('App\Billing\Refund'));      // loader runs, no file, false

unlink("$base/src/Billing/Invoice.php");
rmdir("$base/src/Billing");
rmdir("$base/src");
rmdir($base);
Outputcompiled & run with real PHP
autoload App\Billing\Invoice -> src/Billing/Invoice.php
1,299.00
5.00
autoload App\Billing\Refund -> src/Billing/Refund.php
bool(false)

The loader runs once per class, not once per new. The is_file check matters: without it, class_exists on a missing class would crash on require instead of returning false. If a class "is not found" in a Composer project, check the namespace, the folder name (case matters on Linux) and run composer dump-autoload.

Your turn

Add a second prefix, Lib\ mapped to lib/, write lib/Tax.php with a Lib\Tax class, and use both classes in one script.

Commit the lock file, never vendor/
Commit composer.json and composer.lock so every laptop, CI run and server installs the same versions. Add vendor/ to .gitignore — it is rebuilt by composer install. Run composer audit regularly; it checks your locked versions against known security advisories.
03

PHP + Laravel: route, controller and Eloquent model

Laravel is the most popular PHP framework. A request comes in, the router matches the URL to a controller method, the controller uses Eloquent models (Laravel's ORM: one class per table) and returns a response. Arrays and models are turned into JSON automatically. php artisan is the command-line tool that generates files, runs migrations and starts a dev server.

bash
composer create-project laravel/laravel shop
cd shop
php artisan install:api                    # adds routes/api.php (served under /api)
php artisan make:model Product -mc         # model + migration + controller
php artisan migrate                        # create the tables
php artisan serve                          # http://127.0.0.1:8000
PHP + Laravel

A products API: routes, a controller with validation, and a model

Route model binding: because show takes a Product $product and the route segment is {product}, Laravel loads the row by id and returns 404 if it does not exist. $request->validate() returns only the validated fields, or sends a 422 response with error messages before your next line runs. $fillable is the allow-list for Product::create($data), which is what stops a client from setting columns you never intended (mass assignment).

php
// routes/api.php
use App\Http\Controllers\ProductController;
use Illuminate\Support\Facades\Route;

Route::get('/products', [ProductController::class, 'index']);
Route::get('/products/{product}', [ProductController::class, 'show']);
Route::post('/products', [ProductController::class, 'store']);

// app/Models/Product.php
namespace App\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;

class Product extends Model
{
    protected $fillable = ['name', 'price_cents', 'active'];

    protected function casts(): array
    {
        return ['price_cents' => 'integer', 'active' => 'boolean'];
    }

    public function scopeActive(Builder $query): void   // Product::query()->active()
    {
        $query->where('active', true);
    }
}

// app/Http/Controllers/ProductController.php
namespace App\Http\Controllers;

use App\Models\Product;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

class ProductController extends Controller
{
    public function index()
    {
        return Product::query()->active()->orderBy('name')->paginate(20);
    }

    public function show(Product $product): Product      // 404 if the id does not exist
    {
        return $product;
    }

    public function store(Request $request): JsonResponse
    {
        $data = $request->validate([
            'name'        => ['required', 'string', 'max:120'],
            'price_cents' => ['required', 'integer', 'min:0'],
        ]);
        return response()->json(Product::create($data), 201);
    }
}

// curl -X POST localhost:8000/api/products -H 'Accept: application/json' \
//      -d name=Pen -d price_cents=150
// curl localhost:8000/api/products/1
The N+1 query problem
Looping over 50 orders and reading $order->customer->name runs 1 query for the orders plus 50 for the customers. Load the relation up front with Order::with('customer')->get() (2 queries in total). Laravel can throw on lazy loading in development: call Model::preventLazyLoading() in a service provider.
04

The Laravel service container

Module 06 promised this: in a framework, objects get their dependencies from a service container instead of static state or new inside the class. You ask the container for a class; it reads the constructor with reflection, builds each parameter it needs (recursively), and passes them in. That is dependency injection. You only teach it the cases it cannot guess: which class implements an interface, and which services should be built once and shared (a singleton). This 30-line container does the same thing Laravel's does.

phpmain.php
<?php
declare(strict_types=1);

interface Mailer { public function send(string $to, string $body): string; }
final class LogMailer implements Mailer {
    public function send(string $to, string $body): string { return "LOG to=$to: $body"; }
}
final class Clock { public function today(): string { return '2025-01-15'; } }
final class WelcomeService {
    public function __construct(private Mailer $mailer, private Clock $clock) {}
    public function welcome(string $email): string {
        return $this->mailer->send($email, 'Welcome! Joined ' . $this->clock->today());
    }
}

// A 30-line service container: bindings + autowiring by reflection
final class Container {
    /** @var array<string, Closure> */
    private array $bindings = [];
    /** @var array<string, object> */
    private array $shared = [];

    public function bind(string $abstract, Closure $factory): void { $this->bindings[$abstract] = $factory; }
    public function singleton(string $abstract, Closure $factory): void {
        $this->bindings[$abstract] = function (Container $c) use ($abstract, $factory): object {
            return $this->shared[$abstract] ??= $factory($c);
        };
    }
    public function make(string $class): object {
        if (isset($this->bindings[$class])) {
            return ($this->bindings[$class])($this);
        }
        $ctor = (new ReflectionClass($class))->getConstructor();
        $args = [];
        foreach ($ctor?->getParameters() ?? [] as $p) {
            $args[] = $this->make($p->getType()->getName());   // recurse on each dependency
        }
        echo "  built $class\n";
        return new $class(...$args);
    }
}

$c = new Container();
$c->bind(Mailer::class, fn() => new LogMailer());     // interface -> implementation
$c->singleton(Clock::class, fn() => new Clock());

$service = $c->make(WelcomeService::class);
echo $service->welcome('[email protected]'), "\n";
var_dump($c->make(Clock::class) === $c->make(Clock::class));
var_dump($c->make(Mailer::class) === $c->make(Mailer::class));
Outputcompiled & run with real PHP
  built WelcomeService
LOG [email protected]: Welcome! Joined 2025-01-15
bool(true)
bool(false)

WelcomeService was never bound: the container read its constructor, saw Mailer and Clock, and resolved both from their bindings. The singleton returns the same object every time (true); a plain bind builds a new one each time (false). An interface with no binding would fail, because an interface cannot be instantiated — the exact error Laravel reports as "Target [Mailer] is not instantiable".

Your turn

Add a has(string $abstract): bool method, and make make() throw a clear RuntimeException when asked for an interface that has no binding (check (new ReflectionClass($class))->isInstantiable()).

PHP + Laravel

Binding, injecting and swapping services in Laravel

Bindings live in a service provider's register() method. After that, any controller, job, command or listener can simply type-hint the interface in its constructor. In a test, $this->app->instance() swaps the real implementation for a fake, so the test never sends a real email or charges a real card — this swap is the main practical reason to depend on interfaces.

php
// app/Providers/AppServiceProvider.php
namespace App\Providers;

use App\Billing\HttpPaymentGateway;
use App\Billing\PaymentGateway;
use App\Support\ExchangeRates;
use Illuminate\Support\ServiceProvider;

class AppServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        // interface -> implementation (new object each time)
        $this->app->bind(PaymentGateway::class, HttpPaymentGateway::class);

        // built once per request / worker, with config the container cannot guess
        $this->app->singleton(ExchangeRates::class, fn ($app) => new ExchangeRates(
            apiKey: config('services.rates.key'),
            ttlSeconds: 600,
        ));
    }
}

// app/Http/Controllers/CheckoutController.php — constructor injection
class CheckoutController extends Controller
{
    public function __construct(private PaymentGateway $gateway) {}

    public function __invoke(Order $order): JsonResponse
    {
        $receipt = $this->gateway->charge($order->total_cents, $order->currency);
        return response()->json(['receipt' => $receipt->id]);
    }
}

// tests/Feature/CheckoutTest.php — swap in a fake
public function test_checkout_charges_the_order(): void
{
    $fake = new FakePaymentGateway();
    $this->app->instance(PaymentGateway::class, $fake);

    $this->postJson('/api/orders/1/checkout')->assertOk();
    $this->assertSame(4999, $fake->lastChargeCents);
}
Dependency injection and the patterns frameworks are built on →
Singletons in long-running workers
Under PHP-FPM every request starts fresh, so a singleton lives for one request. Under Laravel Octane, queue workers or Swoole the process stays alive, and a singleton holding per-user data leaks it into the next request. Keep request data out of singletons, or bind it with scoped(), which Laravel resets between requests and jobs.
05

PHP + Symfony: a controller with attribute routing

Symfony is the other major framework, and a set of components that Laravel, Drupal and many others build on (HttpFoundation, Console, Routing). A Symfony app looks similar: routes are PHP 8 attributes on controller methods, services are autowired into constructors by its container, and Doctrine is the usual ORM. Knowing one framework makes the other quick to read.

PHP + Symfony

A JSON endpoint with a route attribute and an injected repository

#[Route] declares the URL, the HTTP method and a requirement that id is digits, so /api/products/abc never reaches the method. The repository arrives through the constructor (autowiring). createNotFoundException() becomes a 404 response. Create a project with composer create-project symfony/skeleton shop, then run it with symfony server:start or php -S localhost:8000 -t public.

php
// src/Controller/ProductController.php
namespace App\Controller;

use App\Repository\ProductRepository;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Attribute\Route;

final class ProductController extends AbstractController
{
    public function __construct(private ProductRepository $products) {}

    #[Route('/api/products/{id}', name: 'product_show', methods: ['GET'], requirements: ['id' => '\d+'])]
    public function show(int $id): JsonResponse
    {
        $product = $this->products->find($id) ?? throw $this->createNotFoundException();

        return $this->json([
            'id'    => $product->getId(),
            'name'  => $product->getName(),
            'price' => $product->getPriceCents() / 100,
        ]);
    }
}

// php bin/console debug:router        list every route
// php bin/console debug:autowiring    list the services you can type-hint

Laravel

  • Routes in routes/*.php files
  • Eloquent: active record (the model saves itself)
  • Facades and helpers for speed
  • Most PHP job listings

Symfony

  • Routes as attributes on controllers
  • Doctrine: data mapper (an EntityManager saves entities)
  • Explicit configuration, strict conventions
  • Common in enterprise and long-lived systems
06

PHP + MySQL and PostgreSQL with PDO

Frameworks talk to the database through PDO, the layer you used in Module 10. Moving from SQLite to a server database changes one thing: the DSN string, plus a user and password that come from environment variables, never from the code. Two habits matter on a real server: always set the connection charset (utf8mb4 on MySQL, or emoji and many scripts get mangled), and wrap multi-step writes in a transaction.

PHP + MySQL

A connection factory for MySQL and PostgreSQL

Configuration comes from the environment (Docker Compose sets it in the Docker lab below). ERRMODE_EXCEPTION is the default since PHP 8.0 but stating it documents intent. EMULATE_PREPARES => false makes MySQL use real server-side prepared statements, so integers come back as int and placeholders are never string-interpolated. The PostgreSQL DSN differs only in its prefix and port, which is the point of PDO.

php
<?php
declare(strict_types=1);

function connect(): PDO
{
    $dsn = getenv('DB_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=shop;charset=utf8mb4';
    // PostgreSQL: 'pgsql:host=127.0.0.1;port=5432;dbname=shop'

    return new PDO($dsn, getenv('DB_USER') ?: 'shop', getenv('DB_PASSWORD') ?: '', [
        PDO::ATTR_ERRMODE            => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES   => false,
    ]);
}

$pdo = connect();
$stmt = $pdo->prepare('SELECT id, name, price_cents FROM products WHERE price_cents <= :max ORDER BY name LIMIT 20');
$stmt->execute(['max' => 1000]);
foreach ($stmt as $row) {
    printf("%d  %-20s %6.2f\n", $row['id'], $row['name'], $row['price_cents'] / 100);
}

// MySQL upsert:      INSERT ... ON DUPLICATE KEY UPDATE stock = VALUES(stock)
// PostgreSQL upsert: INSERT ... ON CONFLICT (sku) DO UPDATE SET stock = EXCLUDED.stock
// PostgreSQL returns ids with: INSERT ... RETURNING id   (MySQL: $pdo->lastInsertId())

A transaction makes several statements succeed or fail together. The code below is plain PDO, so it runs unchanged on MySQL and PostgreSQL; here it uses in-memory SQLite so it can run anywhere. The second transfer would overdraw an account, the CHECK constraint rejects it, and rollBack() undoes the half that had already run.

phpmain.php
<?php
declare(strict_types=1);

// Same PDO code runs on MySQL or PostgreSQL; only the DSN changes.
$pdo = new PDO('sqlite::memory:', options: [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
$pdo->exec('CREATE TABLE accounts (id INTEGER PRIMARY KEY, owner TEXT, balance INTEGER CHECK (balance >= 0))');
$pdo->exec("INSERT INTO accounts (owner, balance) VALUES ('asha', 500), ('ravi', 100)");

function transfer(PDO $pdo, int $from, int $to, int $amount): void {
    $pdo->beginTransaction();
    try {
        $move = $pdo->prepare('UPDATE accounts SET balance = balance + :delta WHERE id = :id');
        $move->execute(['delta' => $amount, 'id' => $to]);
        $move->execute(['delta' => -$amount, 'id' => $from]);   // CHECK fails if overdrawn
        $pdo->commit();
    } catch (PDOException $e) {
        $pdo->rollBack();                                         // undo the first UPDATE too
        throw $e;
    }
}

function balances(PDO $pdo): string {
    $rows = $pdo->query('SELECT owner, balance FROM accounts ORDER BY id')->fetchAll();
    return implode(', ', array_map(fn($r) => "{$r['owner']}={$r['balance']}", $rows));
}

transfer($pdo, 1, 2, 200);
echo balances($pdo), "\n";
try {
    transfer($pdo, 2, 1, 1000);
} catch (PDOException $e) {
    echo "rolled back: ", $e->getCode(), "\n";
}
echo balances($pdo), "\n";
Outputcompiled & run with real PHP
asha=300, ravi=300
rolled back: 23000
asha=300, ravi=300

SQLSTATE 23000 is the standard code for an integrity-constraint violation; MySQL and PostgreSQL report constraint failures in the same family. Without the transaction, ravi would have received 1000 that asha never lost. In Laravel the same thing is DB::transaction(function () { ... }), which commits or rolls back for you.

Your turn

Make transfer reject $amount <= 0 with an InvalidArgumentException before it opens the transaction, and call it with 0 to check.

07

PHP + Redis: caching slow reads

PHP forgets everything between requests, so work that is slow and repeated — a heavy query, an external API call, a rendered fragment — is kept in Redis, an in-memory key-value server shared by all PHP workers. The standard pattern is cache-aside: look in the cache; on a miss, load from the database and store the result with an expiry (TTL); when the data changes, delete the key. This program uses an array with the same get/setex/del shape as Redis and a clock you move by hand, so you can see every hit, miss and expiry.

phpmain.php
<?php
declare(strict_types=1);

// Same shape as Redis GET / SETEX / DEL, backed by an array and a fake clock
final class ArrayCache {
    private array $items = [];
    public int $now = 0;                                   // seconds, moved by hand
    public function get(string $key): ?string {
        $item = $this->items[$key] ?? null;
        return ($item !== null && $item['exp'] > $this->now) ? $item['val'] : null;
    }
    public function setex(string $key, int $ttl, string $val): void {
        $this->items[$key] = ['val' => $val, 'exp' => $this->now + $ttl];
    }
    public function del(string $key): void { unset($this->items[$key]); }
}

$dbQueries = 0;
function loadProduct(int $id): array {                     // the slow part
    global $dbQueries;
    $dbQueries++;
    return ['id' => $id, 'name' => "Product $id", 'price' => 499];
}

// cache-aside: try the cache, fall back to the database, store the result
function product(ArrayCache $cache, int $id): array {
    $key = "product:$id";
    $hit = $cache->get($key);
    if ($hit !== null) {
        return json_decode($hit, true, flags: JSON_THROW_ON_ERROR);
    }
    $row = loadProduct($id);
    $cache->setex($key, 60, json_encode($row, JSON_THROW_ON_ERROR));
    return $row;
}

$cache = new ArrayCache();
product($cache, 7);
product($cache, 7);
product($cache, 7);
echo "after 3 reads: $dbQueries query\n";

$cache->now = 61;                                           // TTL expired
product($cache, 7);
echo "after expiry: $dbQueries queries\n";

$cache->del('product:7');                                   // price changed: invalidate
echo product($cache, 7)['name'], ", queries: $dbQueries\n";
Outputcompiled & run with real PHP
after 3 reads: 1 query
after expiry: 2 queries
Product 7, queries: 3
Your turn

Add a remember(string $key, int $ttl, Closure $load): array method to the cache that wraps the whole cache-aside dance, and rewrite product() as one line that calls it.

PHP + Redis

The same cache with phpredis, and with Laravel's Cache facade

phpredis is the C extension (pecl install redis, or install-php-extensions redis in Docker); Predis is a pure-PHP alternative installed with Composer. Values are strings, so arrays go through json_encode. Always set a TTL — a cache with no expiry slowly fills with stale data. Key names are namespaced with colons (product:7) so you can find and delete them by pattern.

php
<?php
$redis = new Redis();
$redis->connect(getenv('REDIS_HOST') ?: '127.0.0.1', 6379);

function product(Redis $redis, PDO $pdo, int $id): array
{
    $key = "product:$id";
    $cached = $redis->get($key);                 // false on a miss
    if ($cached !== false) {
        return json_decode($cached, true, flags: JSON_THROW_ON_ERROR);
    }
    $stmt = $pdo->prepare('SELECT id, name, price_cents FROM products WHERE id = ?');
    $stmt->execute([$id]);
    $row = $stmt->fetch() ?: throw new RuntimeException("No product $id");
    $redis->setex($key, 600, json_encode($row, JSON_THROW_ON_ERROR));   // 10 minutes
    return $row;
}

// after an update, invalidate:  $redis->del("product:$id");
// a simple rate limit:          $n = $redis->incr("login:$ip"); if ($n === 1) $redis->expire("login:$ip", 60);

// Laravel (CACHE_STORE=redis in .env):
// $product = Cache::remember("product:$id", 600, fn () => Product::findOrFail($id));
// Cache::forget("product:$id");
08

PHP + PHPUnit: automated tests

PHPUnit is PHP's standard test framework, installed per project with composer require --dev phpunit/phpunit. A test is a class extending TestCase in tests/; each public method whose name starts with test (or has the #[Test] attribute) is one test. Assertions check results; expectException checks that bad input fails the way you promised; a data provider runs one test over a table of inputs. Pest is a popular, terser layer on top of PHPUnit, and Laravel ships with both.

PHP + Composer

PHPUnit tests for a shipping-fee rule, installed with Composer

Each test follows Arrange, Act, Assert. The data provider replaces five copy-pasted tests with one method and a table, and the keys ('just below') name each case in the failure output. Use assertSame (checks type and value, like ===) rather than assertEquals (like ==), so "499" never passes for 499. Run with vendor/bin/phpunit; a failure prints the expected and actual values.

php
// src/Shipping.php
namespace App;

final class Shipping
{
    /** Free over 50.00, otherwise 4.99 plus 1.00 per started kilo over 2 kg. */
    public static function feeCents(int $orderCents, int $grams): int
    {
        if ($grams <= 0) {
            throw new \InvalidArgumentException("weight must be positive, got $grams");
        }
        if ($orderCents >= 5000) {
            return 0;
        }
        $extraKilos = max(0, (int) ceil(($grams - 2000) / 1000));
        return 499 + 100 * $extraKilos;
    }
}

// tests/ShippingTest.php
namespace App\Tests;

use App\Shipping;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

final class ShippingTest extends TestCase
{
    public static function cases(): array
    {
        return [
            'light, small order' => [1000, 500, 499],
            'exactly 2 kg'       => [1000, 2000, 499],
            'just over 2 kg'     => [1000, 2001, 599],
            'just below free'    => [4999, 500, 499],
            'free at 50.00'      => [5000, 9000, 0],
        ];
    }

    #[DataProvider('cases')]
    public function testFee(int $orderCents, int $grams, int $expected): void
    {
        $this->assertSame($expected, Shipping::feeCents($orderCents, $grams));
    }

    public function testZeroWeightIsRejected(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('got 0');
        Shipping::feeCents(1000, 0);
    }
}

// vendor/bin/phpunit                      run everything
// vendor/bin/phpunit --filter testFee     run one test
// XDEBUG_MODE=coverage vendor/bin/phpunit --coverage-text
xmlphpunit.xml
<?xml version="1.0" encoding="UTF-8"?>
<phpunit bootstrap="vendor/autoload.php" colors="true" failOnWarning="true" failOnDeprecation="true">
  <testsuites>
    <testsuite name="unit">
      <directory>tests</directory>
    </testsuite>
  </testsuites>
  <source>
    <include>
      <directory>src</directory>
    </include>
  </source>
</phpunit>

failOnWarning and failOnDeprecation turn the warnings from Module 11 into test failures, so an "Undefined array key" cannot hide behind a green build.

Test the edges
The cases worth writing are boundaries: exactly at a threshold (2 kg, 50.00), one step either side, and invalid input. That is the "test small cases" habit from Problem Solving, written down so it runs on every commit.
09

PHP + Docker: PHP-FPM behind Nginx

In production PHP is not a web server. Nginx accepts HTTP, serves static files itself, and forwards .php requests over FastCGI to PHP-FPM, a pool of PHP worker processes. Docker Compose runs that pair plus MySQL and Redis with one command, the same on every laptop and server. The official php:8.5-fpm-alpine image has PHP-FPM listening on port 9000; you add the extensions and your code.

PHP + Docker

A PHP-FPM image and a Compose stack with Nginx, MySQL and Redis

The Dockerfile copies composer.json and the lock file first and installs dependencies before copying the code, so Docker reuses that slow layer until the dependencies change. It runs as www-data, not root. In Compose, services reach each other by name: Nginx forwards to app:9000, PHP connects to db and redis. depends_on with a health check waits until MySQL actually accepts connections. Real passwords belong in a .env file or a secret store, not in the YAML.

dockerfile
# Dockerfile
FROM php:8.5-fpm-alpine

# PHP extensions (the installer handles build dependencies for you)
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions pdo_mysql redis opcache

COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html

# dependencies first: this layer is cached until composer.lock changes
COPY composer.json composer.lock ./
RUN composer install --no-dev --no-scripts --no-autoloader --prefer-dist --no-interaction

COPY . .
RUN composer dump-autoload --no-dev --classmap-authoritative

USER www-data
# php-fpm listens on 9000 (inherited CMD)

# ---------------------------------------------------------------
# compose.yaml
# services:
#   app:
#     build: .
#     environment:
#       DB_DSN: "mysql:host=db;dbname=shop;charset=utf8mb4"
#       DB_USER: shop
#       DB_PASSWORD: secret
#       REDIS_HOST: redis
#     depends_on:
#       db: { condition: service_healthy }
#       redis: { condition: service_started }
#   web:
#     image: nginx:1.27-alpine
#     ports: ["8080:80"]
#     volumes:
#       - ./public:/var/www/html/public:ro
#       - ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
#     depends_on: [app]
#   db:
#     image: mysql:8.4
#     environment:
#       MYSQL_DATABASE: shop
#       MYSQL_USER: shop
#       MYSQL_PASSWORD: secret
#       MYSQL_ROOT_PASSWORD: rootsecret
#     volumes: [dbdata:/var/lib/mysql]
#     healthcheck:
#       test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
#       interval: 5s
#       retries: 10
#   redis:
#     image: redis:7-alpine
# volumes:
#   dbdata:
#
# docker compose up -d --build      then open http://localhost:8080
# docker compose exec app php -v    run commands inside the PHP container
nginxdocker/nginx.conf
server {
    listen 80;
    root /var/www/html/public;          # only public/ is web-reachable
    index index.php;

    # front controller: any path that is not a real file goes to index.php
    location / {
        try_files $uri $uri/ /index.php?$query_string;
    }

    location ~ \.php$ {
        fastcgi_pass app:9000;           # the php-fpm service
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }

    location ~ /\.(?!well-known) {       # never serve .env, .git, ...
        deny all;
    }
}

The try_files line is what makes the front-controller router from Module 10 work behind a real server. Pointing root at public/ keeps vendor/, .env and your source out of reach of the browser.

In real jobs
Two php.ini settings change production speed the most: OPcache on (compiled scripts are kept in memory instead of being parsed on every request) and opcache.validate_timestamps=0 in immutable containers, since the code never changes after the image is built. PHP-FPM's pm.max_children caps how many requests run at once; size it from your memory limit per worker.
10

PHP + GitHub Actions: CI with PHPStan and PHPUnit

Continuous integration runs your checks on every push and pull request, so a broken change is caught before it is merged, not after it is deployed. A typical PHP pipeline: install dependencies from the lock file, validate composer.json, run PHPStan (the static analyser from Module 11), then run PHPUnit. Any failing step turns the pull request red.

PHP + GitHub

A GitHub Actions workflow: Composer install, PHPStan, PHPUnit

shivammathur/setup-php installs the PHP version and extensions you ask for on the runner. composer validate --strict catches a lock file that is out of date with composer.json. PHPStan reads its settings from phpstan.neon. To test several PHP versions, add a strategy.matrix with a list of versions and use the matrix value as php-version.

yaml
# .github/workflows/ci.yml
name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.5'
          extensions: pdo_sqlite, mbstring
          coverage: none

      - name: Validate composer.json and composer.lock
        run: composer validate --strict

      - name: Install dependencies
        run: composer install --no-interaction --prefer-dist --no-progress

      - name: Static analysis
        run: vendor/bin/phpstan analyse --no-progress

      - name: Tests
        run: vendor/bin/phpunit
yamlphpstan.neon
parameters:
    level: 6          # 0 = basic checks ... 10 = strictest; raise it as you fix errors
    paths:
        - src
        - tests
text.gitignore
/vendor/
.env
.phpunit.cache/
# Symfony cache and logs
/var/
# Laravel encryption keys
/storage/*.key
/node_modules/

Commit composer.lock; ignore vendor/ and .env. A leaked .env in a public repository is one of the most common ways database passwords escape.

Adding PHPStan to an old codebase
A legacy project may report thousands of errors at level 6. Run vendor/bin/phpstan analyse --generate-baseline once: it records today's errors in phpstan-baseline.neon and fails only on new ones. Then shrink the baseline a little with every pull request. Add PHP-CS-Fixer or PHP_CodeSniffer as another step to enforce the PSR-12 coding style.
Composer
PHP's package manager: installs libraries from Packagist into vendor/ and generates the autoloader.
composer.lock
The exact versions Composer installed. Commit it so every environment gets the same code.
PSR-4
The autoloading standard: a namespace prefix maps to a directory, and the rest of the class name is the file path.
Autoloader
A function registered with spl_autoload_register that loads a class file the first time the class is used.
Service container
The framework object that builds your classes and injects their dependencies, using bindings and reflection.
Dependency injection
Passing a class the objects it needs (usually through its constructor) instead of creating them inside it.
Eloquent
Laravel's ORM: one model class per table, using the active-record pattern.
Route model binding
Laravel loads the model named by a route parameter automatically, returning 404 if it does not exist.
Cache-aside
Read from the cache; on a miss load from the database and store the result with a TTL; delete the key when data changes.
PHP-FPM
FastCGI Process Manager: a pool of PHP workers that a web server such as Nginx forwards requests to.
OPcache
The PHP extension that keeps compiled scripts in shared memory so they are not re-parsed on every request.
PHPStan
A static analyser that finds type and logic errors without running the code; levels 0 to 10.
Quick check

In a composer.json, "autoload": {"psr-4": {"App\\": "src/"}}. Where must the class App\Http\Kernel live?

Quick check

A Laravel queue worker binds CurrentCart as a singleton and stores the logged-in user's items in it. What goes wrong?

Frequently asked questions

Should I learn Laravel or Symfony first?
Learn Laravel first if your goal is a PHP job: it appears in the most listings and gets you productive fastest. Symfony is common in enterprise and long-lived systems, and its components sit underneath Laravel, so the concepts (routing, controllers, a service container, an ORM, migrations) transfer directly once you know one of them.
Do I need Docker to develop PHP?
No. php -S and a local MySQL are enough to learn. Teams use Docker (often through Laravel Sail or DDEV) so every developer runs the same PHP version, extensions, database and cache as production, which removes most "works on my machine" bugs.
Should composer.lock be committed to Git?
Yes, for applications. The lock file pins the exact version of every package so every laptop, CI run and server installs identical code. Libraries published to Packagist usually do not commit it, because their users resolve versions themselves. Never commit the vendor directory.

Finish the PHP handbook, then get hired

Sit the exam for your certificate, run your resume through the ATS checker, and see the jobs that ask for exactly this.

Check my resume
Found this course useful? Share it.
ShareXLinkedIn

Comments

0

Join the conversation. Sign in to leave a comment — we'd love to hear your thoughts.