All topics
Backend · Learning hub

PHP notes for developers

Master PHP with a curated set of 6 developer notes — core concepts, patterns, and interview prep. Maintained by the DevRecall team.

Save this stack to your DevRecallMore Backend notes
PHP

Syntax, Types & OOP

PHP: Syntax, Types & OOP PHP is a server-side scripting language designed for web development. PHP 8.x introduced JIT compilation, union types, fibers, readonly

PHP: Syntax, Types & OOP

PHP is a server-side scripting language designed for web development. PHP 8.x introduced JIT compilation, union types, fibers, readonly properties, and many modern language features that rival Python and Ruby.

Core Syntax

<?php

// Variables (dynamically typed)
$name = "Alice";
$age = 30;
$price = 9.99;
$active = true;
$nothing = null;

// String interpolation
echo "Hello, $name! You are $age years old.\n";
echo "Item costs {$item->price} USD\n";  // complex expressions need braces
echo 'No $interpolation here';            // single quotes = literal

// Heredoc / Nowdoc
$sql = <<<EOT
    SELECT *
    FROM users
    WHERE name = '$name'
EOT;

$literal = <<<'EOT'
    No $interpolation in nowdoc
EOT;

// Type juggling & casting
$num = (int) "42px";      // 42
$str = (string) 3.14;     // "3.14"
$bool = (bool) "";        // false
$arr = (array) "hello";   // ["hello"]

// Strict comparison
var_dump(0 == "a");   // true (loose) — avoid
var_dump(0 === "a");  // false (strict) — prefer

// Null coalescing
$username = $_GET['user'] ?? 'Guest';
$config['timeout'] ??= 30;  // assign if null

// Spread operator
$args = [1, 2, 3];
function sum(int ...$nums): int { return array_sum($nums); }
sum(...$args);

Arrays

// Indexed array
$fruits = ['apple', 'banana', 'cherry'];
$fruits[] = 'date';              // append
$fruits[0] = 'avocado';         // update
count($fruits);                  // 4
array_push($fruits, 'elderberry');
$last = array_pop($fruits);
array_unshift($fruits, 'first');
$first = array_shift($fruits);

// Associative array (like a map/dict)
$user = [
    'name' => 'Alice',
    'age' => 30,
    'roles' => ['admin', 'editor'],
];
$user['email'] = 'alice@example.com';
isset($user['phone']);            // false

// Array functions
sort($arr);                      // sort indexed (modifies in place)
rsort($arr);                     // reverse sort
asort($arr);                     // sort by value, preserve keys
ksort($arr);                     // sort by key
array_map(fn($x) => $x * 2, $arr);
array_filter($arr, fn($x) => $x > 0);
array_reduce($arr, fn($carry, $item) => $carry + $item, 0);
in_array('banana', $fruits);     // true/false
array_key_exists('name', $user); // true/false
array_keys($user);               // ['name', 'age', 'roles', 'email']
array_values($user);
array_merge($arr1, $arr2);
array_slice($arr, 1, 3);         // offset 1, length 3
array_splice($arr, 1, 2, ['x']); // remove 2, insert 'x' at pos 1
array_unique($arr);              // remove duplicates
array_flip($arr);                // swap keys and values
array_column($users, 'email');   // extract column from 2D array

OOP

<?php

// Modern PHP class
class User
{
    public function __construct(
        private readonly int $id,
        private string $name,
        private string $email,
        private array $roles = [],
    ) {}

    public function getName(): string { return $this->name; }

    public function rename(string $name): void
    {
        if (strlen($name) < 2) {
            throw new \InvalidArgumentException("Name too short");
        }
        $this->name = $name;
    }

    public function hasRole(string $role): bool
    {
        return in_array($role, $this->roles, strict: true);
    }

    public function withRole(string $role): static  // covariant return
    {
        $clone = clone $this;
        $clone->roles[] = $role;
        return $clone;
    }

    public function toArray(): array
    {
        return ['id' => $this->id, 'name' => $this->name, 'email' => $this->email];
    }

    // Magic methods
    public function __toString(): string { return $this->name; }
    public function __get(string $prop): mixed { return $this->$prop ?? null; }
}

// Interface + abstract class
interface Notifiable {
    public function notify(string $message): void;
}

abstract class BaseModel {
    abstract public function validate(): bool;

    public function save(): void {
        if ($this->validate()) {
            // save to DB
        }
    }
}

// Traits (mixins)
trait Timestamps {
    private \DateTimeImmutable $createdAt;

    public function initTimestamps(): void {
        $this->createdAt = new \DateTimeImmutable();
    }

    public function getCreatedAt(): \DateTimeImmutable {
        return $this->createdAt;
    }
}

// Enums (PHP 8.1+)
enum Status: string {
    case Active = 'active';
    case Inactive = 'inactive';
    case Pending = 'pending';

    public function label(): string {
        return match($this) {
            Status::Active => 'Active',
            Status::Inactive => 'Inactive',
            Status::Pending => 'Awaiting Review',
        };
    }
}

$status = Status::Active;
$status->value;          // 'active'
$status->label();        // 'Active'
Status::from('pending'); // Status::Pending
PHP

Web Features, Database & Error Handling

PHP: Web Features, Database & Error Handling Superglobals & Request Handling <?php // Input (always sanitize/validate before use) $name = filter_input(INPUT_GET

PHP: Web Features, Database & Error Handling

Superglobals & Request Handling

<?php

// Input (always sanitize/validate before use)
$name = filter_input(INPUT_GET, 'name', FILTER_SANITIZE_SPECIAL_CHARS);
$age = filter_input(INPUT_POST, 'age', FILTER_VALIDATE_INT);
$email = filter_input(INPUT_POST, 'email', FILTER_VALIDATE_EMAIL);

// Raw access (validate before trusting)
$_GET['query'];          // URL params
$_POST['data'];          // POST body (form-encoded)
$_SERVER['REQUEST_METHOD'];
$_SERVER['HTTP_USER_AGENT'];
$_SERVER['REMOTE_ADDR'];
$_SERVER['REQUEST_URI'];

// JSON body (APIs)
$body = json_decode(file_get_contents('php://input'), associative: true);
$userId = $body['user_id'] ?? null;

// File uploads
if (isset($_FILES['photo']) && $_FILES['photo']['error'] === UPLOAD_ERR_OK) {
    $tmpPath = $_FILES['photo']['tmp_name'];
    $fileName = basename($_FILES['photo']['name']);
    // Validate MIME type!
    $mimeType = mime_content_type($tmpPath);
    if (in_array($mimeType, ['image/jpeg', 'image/png', 'image/webp'])) {
        move_uploaded_file($tmpPath, '/uploads/' . uniqid() . '_' . $fileName);
    }
}

// Sessions
session_start();
$_SESSION['user_id'] = 42;
$_SESSION['cart'] = [];
session_regenerate_id(delete_old_session: true);  // prevent fixation
session_destroy();

// Cookies
setcookie('remember_token', $token, [
    'expires' => time() + 86400 * 30,
    'path' => '/',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

PDO — Database Access

<?php

// Connect
$pdo = new PDO(
    'mysql:host=localhost;dbname=myapp;charset=utf8mb4',
    'user',
    'password',
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

// Prepared statements (always use — prevents SQL injection)
$stmt = $pdo->prepare('SELECT * FROM users WHERE email = ? AND active = ?');
$stmt->execute([$email, true]);
$user = $stmt->fetch();

// Named placeholders
$stmt = $pdo->prepare('INSERT INTO users (name, email, created_at) VALUES (:name, :email, NOW())');
$stmt->execute(['name' => $name, 'email' => $email]);
$id = $pdo->lastInsertId();

// Fetch multiple rows
$stmt = $pdo->prepare('SELECT * FROM articles WHERE status = ? ORDER BY created_at DESC LIMIT ?');
$stmt->execute(['published', 10]);
$articles = $stmt->fetchAll();

// Single value
$count = $pdo->query('SELECT COUNT(*) FROM users')->fetchColumn();

// Transactions
try {
    $pdo->beginTransaction();
    $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE id = ?')
        ->execute([$amount, $fromId]);
    $pdo->prepare('UPDATE accounts SET balance = balance + ? WHERE id = ?')
        ->execute([$amount, $toId]);
    $pdo->commit();
} catch (\Exception $e) {
    $pdo->rollBack();
    throw $e;
}

Error Handling & Exceptions

<?php

// Custom exception hierarchy
class AppException extends \RuntimeException {}
class ValidationException extends AppException {
    public function __construct(private array $errors) {
        parent::__construct('Validation failed');
    }
    public function getErrors(): array { return $this->errors; }
}
class NotFoundException extends AppException {}

// Structured error handling
function findUser(int $id): array
{
    $user = $db->find($id);
    if ($user === null) {
        throw new NotFoundException("User $id not found");
    }
    return $user;
}

try {
    $user = findUser($id);
} catch (NotFoundException $e) {
    http_response_code(404);
    echo json_encode(['error' => $e->getMessage()]);
} catch (ValidationException $e) {
    http_response_code(422);
    echo json_encode(['errors' => $e->getErrors()]);
} catch (\Throwable $e) {  // catches Error + Exception
    error_log($e->getMessage());
    http_response_code(500);
    echo json_encode(['error' => 'Internal server error']);
} finally {
    // Always runs
}

// Global error handler
set_exception_handler(function (\Throwable $e) {
    error_log($e->getMessage() . '\n' . $e->getTraceAsString());
    http_response_code(500);
    echo json_encode(['error' => 'Unexpected error']);
});

// Error to exception conversion
set_error_handler(function (int $errno, string $errstr, string $errfile, int $errline) {
    throw new \ErrorException($errstr, 0, $errno, $errfile, $errline);
});
PHP

Modern PHP: Composer, Fibers & Best Practices

PHP: Modern PHP, Composer & Best Practices Composer # Install Composer curl -sS https://getcomposer.org/installer | php sudo mv composer.phar /usr/local/bin/com

PHP: Modern PHP, Composer & Best Practices

Composer

# Install Composer
curl -sS https://getcomposer.org/installer | php
sudo mv composer.phar /usr/local/bin/composer

# Create project
composer init
composer create-project laravel/laravel myapp

# Install packages
composer require guzzlehttp/guzzle
composer require --dev phpunit/phpunit psalm/psalm

# Update dependencies
composer update
composer update guzzlehttp/guzzle  # single package

# Autoloading (PSR-4)
# composer.json:
# "autoload": { "psr-4": { "App\\": "src/" } }
composer dump-autoload

# Scripts
# composer.json: "scripts": { "test": "vendor/bin/phpunit", "lint": "vendor/bin/psalm" }
composer test
composer lint

PHP 8.x Features

<?php

// Named arguments (PHP 8.0)
array_slice(array: $arr, offset: 1, length: 3, preserve_keys: true);

// Match expression (PHP 8.0) — strict, no fallthrough, exhaustive
$label = match($status) {
    'active', 'enabled' => 'Active',
    'inactive' => 'Inactive',
    default => throw new \ValueError("Unknown status: $status"),
};

// Nullsafe operator (PHP 8.0)
$country = $user?->getAddress()?->getCountry()?->getName();

// Union types (PHP 8.0)
function parseId(int|string $id): User { ... }

// Intersection types (PHP 8.1)
function process(Iterator&Countable $collection): void { ... }

// Readonly properties (PHP 8.1)
class Point {
    public function __construct(
        public readonly float $x,
        public readonly float $y,
    ) {}
}

// Fibers (PHP 8.1) — cooperative concurrency
$fiber = new Fiber(function (): void {
    $value = Fiber::suspend('first');   // yields 'first', receives next value
    echo "Resumed with: $value\n";
    Fiber::suspend('second');
});

$val1 = $fiber->start();           // 'first'
$val2 = $fiber->resume('hello');   // 'second'

// First class callables (PHP 8.1)
$fn = strlen(...);
$fn("hello");   // 5

// Readonly classes (PHP 8.2)
readonly class Money {
    public function __construct(
        public int $amount,
        public string $currency,
    ) {}
}

// Typed class constants (PHP 8.3)
class Config {
    const string VERSION = '1.0.0';
    const int MAX_RETRIES = 3;
}

Best Practices

  • Use strict_types: add declare(strict_types=1) at the top of every PHP file — prevents type coercion bugs.

  • Type declarations everywhere: parameters, return types, properties. Use union types and nullable (?) where needed.

  • Use PDO with prepared statements always — never concatenate user input into SQL queries.

  • Hash passwords with password_hash() and verify with password_verify() — never md5 or sha1.

  • Validate and sanitize all input: filter_input(), FILTER_VALIDATE_*, or a validation library.

  • Use Composer for all dependencies. Never copy-paste vendor code into your project.

  • Follow PSR-12 coding standard. Use PHP_CodeSniffer or PHP-CS-Fixer for enforcement.

  • Use a static analyzer: PHPStan (level 8+) or Psalm catches type errors without running the code.

  • Major frameworks: Laravel (most popular, batteries-included), Symfony (enterprise, modular), Slim (microframework for APIs).

PHP

Security: XSS, CSRF, Injection & Auth

PHP Security: XSS, CSRF, Injection & Auth PHP applications are high-value targets because so much of the web runs PHP. Most vulnerabilities fall into a handful

PHP Security: XSS, CSRF, Injection & Auth

PHP applications are high-value targets because so much of the web runs PHP. Most vulnerabilities fall into a handful of categories — understanding each one and its mitigation is essential for any PHP developer.

XSS — Cross-Site Scripting

<?php
// XSS: attacker injects <script> that runs in victim's browser

// NEVER do this:
echo $_GET['name'];              // raw user input in HTML
echo "<b>" . $_POST['msg'] . "</b>";

// ALWAYS escape output with the correct context function:
// HTML context — htmlspecialchars (converts < > " ' & to entities)
echo htmlspecialchars($name, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');

// Helper function
function e(string $value): string {
    return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
echo "<p>Hello, " . e($_GET['name']) . "</p>";

// HTML attribute context
echo '<input value="' . e($value) . '">';

// URL context
echo '<a href="' . e(urlencode($path)) . '">Link</a>';

// JavaScript context — use json_encode
echo '<script>var data = ' . json_encode($data, JSON_HEX_TAG | JSON_HEX_AMP) . ';</script>';

// Content Security Policy header (defense-in-depth)
header("Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self'");

CSRF — Cross-Site Request Forgery

<?php
// CSRF: malicious site tricks authenticated user into submitting a form to your app

// 1. Generate token (store in session)
session_start();
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// 2. Embed in every state-changing form
?>
<form method="POST" action="/transfer">
    <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token']) ?>">
    <input type="text" name="amount">
    <button type="submit">Transfer</button>
</form>
<?php

// 3. Validate on every POST/PUT/DELETE
function validateCsrfToken(): void {
    session_start();
    $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'] ?? '', $token)) {
        http_response_code(403);
        die('Invalid CSRF token');
    }
}

// SameSite cookie attribute (additional mitigation)
session_set_cookie_params([
    'samesite' => 'Lax',   // or 'Strict'
    'secure' => true,
    'httponly' => true,
]);

SQL Injection & File Upload

<?php
// SQL Injection — always use prepared statements (see PDO section)
// Bad:
$id = $_GET['id'];
$result = $pdo->query("SELECT * FROM users WHERE id = $id");  // NEVER

// Good: prepared statement with bound parameter (see page 2)

// Command injection — never pass user input to shell
// Bad:
system("convert " . $_POST['filename'] . " output.jpg");  // NEVER

// Good: validate strictly, use escapeshellarg if unavoidable
$filename = basename($_POST['filename']);
if (!preg_match('/^[a-zA-Z0-9_.-]+$/', $filename)) {
    die('Invalid filename');
}
system("convert " . escapeshellarg("/uploads/" . $filename) . " output.jpg");

// File upload security
function handleUpload(array $file): string {
    // 1. Check for upload errors
    if ($file['error'] !== UPLOAD_ERR_OK) {
        throw new \RuntimeException('Upload error');
    }
    // 2. Validate real MIME type (not Content-Type header — easily spoofed)
    $finfo = new \finfo(FILEINFO_MIME_TYPE);
    $mimeType = $finfo->file($file['tmp_name']);
    $allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
    if (!in_array($mimeType, $allowed, true)) {
        throw new \RuntimeException('Invalid file type');
    }
    // 3. Validate size
    if ($file['size'] > 5 * 1024 * 1024) {
        throw new \RuntimeException('File too large');
    }
    // 4. Generate safe filename (never trust original name)
    $ext = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'][$mimeType];
    $newName = bin2hex(random_bytes(16)) . '.' . $ext;
    // 5. Store outside webroot or use randomized path
    $dest = '/var/app/uploads/' . $newName;
    move_uploaded_file($file['tmp_name'], $dest);
    return $newName;
}

Password Hashing & Session Security

<?php
// Password hashing — bcrypt (cost 12+ recommended)
$hash = password_hash($plaintext, PASSWORD_BCRYPT, ['cost' => 12]);
// or: PASSWORD_ARGON2ID (more secure, requires libsodium)
$hash = password_hash($plaintext, PASSWORD_ARGON2ID);

// Verify
if (password_verify($plaintext, $hash)) {
    // authenticated

    // Rehash if algorithm/cost changed
    if (password_needs_rehash($hash, PASSWORD_ARGON2ID)) {
        $newHash = password_hash($plaintext, PASSWORD_ARGON2ID);
        // save $newHash to DB
    }
}

// Cryptographically secure random values
$token = bin2hex(random_bytes(32));    // 64-char hex token
$otp = random_int(100000, 999999);     // 6-digit OTP

// Constant-time comparison (prevents timing attacks)
hash_equals($expectedToken, $submittedToken);  // never use ===

// Secure session configuration (php.ini or ini_set)
ini_set('session.cookie_httponly', 1);
ini_set('session.cookie_secure', 1);
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', 1);   // reject uninitialized session IDs
ini_set('session.gc_maxlifetime', 3600);

session_start();
// Regenerate ID after privilege change (login, sudo)
session_regenerate_id(delete_old_session: true);

Security Headers & Tips

  • Set security headers: Content-Security-Policy, X-Frame-Options: DENY, X-Content-Type-Options: nosniff, Referrer-Policy.

  • Keep PHP version current — EOL versions receive no security patches. Check php.net/supported-versions.

  • Disable dangerous php.ini settings: expose_php=Off, display_errors=Off (in production), allow_url_fopen=Off.

  • Use HTTPS everywhere. Redirect HTTP → HTTPS at the web server level, not PHP.

  • Rate-limit authentication endpoints to prevent brute-force. Log failed attempts.

  • Store secrets in environment variables (never in code or DB). Use phpdotenv in development.

  • Use a security scanning tool: Psalm taint analysis, RIPS, or SonarQube for static analysis.

PHP

Testing: PHPUnit & Pest

PHP Testing: PHPUnit & Pest PHPUnit is the standard PHP testing framework. Pest is a modern testing framework built on top of PHPUnit with a more expressive syn

PHP Testing: PHPUnit & Pest

PHPUnit is the standard PHP testing framework. Pest is a modern testing framework built on top of PHPUnit with a more expressive syntax. Both produce the same output and can run the same tests.

PHPUnit Setup

composer require --dev phpunit/phpunit

# phpunit.xml (configuration)
# <?xml version="1.0"?>
# <phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
#          xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
#          bootstrap="vendor/autoload.php"
#          colors="true">
#   <testsuites>
#     <testsuite name="Unit">
#       <directory>tests/Unit</directory>
#     </testsuite>
#     <testsuite name="Integration">
#       <directory>tests/Integration</directory>
#     </testsuite>
#   </testsuites>
# </phpunit>

./vendor/bin/phpunit                   # run all tests
./vendor/bin/phpunit tests/Unit        # specific directory
./vendor/bin/phpunit --filter CartTest # specific test class
./vendor/bin/phpunit --filter testAdd  # specific test method
./vendor/bin/phpunit --coverage-html coverage/

PHPUnit Tests

<?php

use PHPUnit\Framework\TestCase;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;

class CartTest extends TestCase
{
    private Cart $cart;

    protected function setUp(): void
    {
        $this->cart = new Cart();
    }

    protected function tearDown(): void
    {
        // cleanup after each test
    }

    #[Test]
    public function it_starts_empty(): void
    {
        $this->assertCount(0, $this->cart->items());
        $this->assertSame(0.0, $this->cart->total());
    }

    public function testAddingItemIncreasesTotal(): void
    {
        $this->cart->add(new Item('Widget', 9.99));
        $this->assertSame(9.99, $this->cart->total());
        $this->assertCount(1, $this->cart->items());
    }

    public function testDuplicateItemIncreasesQuantity(): void
    {
        $item = new Item('Widget', 9.99);
        $this->cart->add($item);
        $this->cart->add($item);
        $this->assertCount(1, $this->cart->items());
        $this->assertSame(19.98, $this->cart->total());
    }

    public function testRemovingNonexistentItemThrows(): void
    {
        $this->expectException(\InvalidArgumentException::class);
        $this->expectExceptionMessage('Item not in cart');
        $this->cart->remove(new Item('Ghost', 0.0));
    }

    // Data providers — run same test with multiple inputs
    #[DataProvider('discountProvider')]
    public function testDiscount(float $subtotal, float $pct, float $expected): void
    {
        $this->assertSame($expected, applyDiscount($subtotal, $pct));
    }

    public static function discountProvider(): array
    {
        return [
            'ten percent off'  => [100.0, 10, 90.0],
            'no discount'      => [50.0,  0,  50.0],
            'full discount'    => [80.0, 100, 0.0],
        ];
    }

    // Assertions reference
    public function testAssertions(): void
    {
        $this->assertTrue($expr);
        $this->assertFalse($expr);
        $this->assertSame($expected, $actual);    // === strict
        $this->assertEquals($expected, $actual);  // == loose
        $this->assertNull($value);
        $this->assertNotNull($value);
        $this->assertCount(3, $collection);
        $this->assertEmpty($collection);
        $this->assertContains($needle, $haystack);
        $this->assertArrayHasKey('key', $array);
        $this->assertInstanceOf(User::class, $obj);
        $this->assertStringContainsString('needle', $str);
        $this->assertMatchesRegularExpression('/pattern/', $str);
    }
}

Mocking

<?php

class OrderServiceTest extends TestCase
{
    public function testCreatesOrderAndSendsEmail(): void
    {
        // Create mock of PaymentGateway interface
        $payment = $this->createMock(PaymentGateway::class);
        $payment->expects($this->once())
                 ->method('charge')
                 ->with(9999, 'USD')   // assert called with these args
                 ->willReturn(new PaymentResult(success: true, transactionId: 'txn_123'));

        $mailer = $this->createMock(Mailer::class);
        $mailer->expects($this->once())
                ->method('send')
                ->with($this->callback(fn($email) => $email->to() === 'user@example.com'));

        $service = new OrderService($payment, $mailer);
        $order = $service->create(userId: 1, amount: 9999, currency: 'USD');

        $this->assertTrue($order->isPaid());
        $this->assertSame('txn_123', $order->transactionId());
    }

    public function testRefundsWhenEmailFails(): void
    {
        $payment = $this->createMock(PaymentGateway::class);
        $payment->expects($this->once())->method('charge')->willReturn(new PaymentResult(true, 'txn_456'));
        $payment->expects($this->once())->method('refund')->with('txn_456');  // assert refund called

        $mailer = $this->createMock(Mailer::class);
        $mailer->method('send')->willThrowException(new MailerException('SMTP error'));

        $this->expectException(OrderException::class);
        (new OrderService($payment, $mailer))->create(1, 9999, 'USD');
    }
}

Pest

<?php

// tests/Unit/CartTest.php — Pest syntax
use function Pest\Laravel\{get, post, actingAs};

// Simple test
it('starts empty', function () {
    $cart = new Cart();
    expect($cart->items())->toBeEmpty();
    expect($cart->total())->toBe(0.0);
});

// Grouped tests
describe('Cart', function () {
    beforeEach(function () {
        $this->cart = new Cart();
    });

    it('adds items', function () {
        $this->cart->add(new Item('Widget', 9.99));
        expect($this->cart->total())->toBe(9.99);
    });

    it('throws when removing missing item', function () {
        expect(fn() => $this->cart->remove(new Item('Ghost', 0))
        )->toThrow(\InvalidArgumentException::class, 'Item not in cart');
    });
});

// Data-driven with dataset()
it('applies discount correctly', function (float $subtotal, float $pct, float $expected) {
    expect(applyDiscount($subtotal, $pct))->toBe($expected);
})->with([
    'ten percent'  => [100.0, 10, 90.0],
    'no discount'  => [50.0,  0, 50.0],
]);

// Pest expectations
expect($value)
    ->toBe(42)
    ->toBeTrue()
    ->toBeFalse()
    ->toBeNull()
    ->toBeEmpty()
    ->toBeInstanceOf(User::class)
    ->toContain('needle')
    ->toHaveCount(3)
    ->toHaveKey('name')
    ->toMatchArray(['name' => 'Alice'])
    ->toBeGreaterThan(0)
    ->not->toBeNull();
PHP

Frameworks: Laravel & Symfony

PHP Frameworks: Laravel & Symfony Laravel — Key Concepts Laravel is the most popular PHP framework. It follows MVC, ships with Eloquent ORM, Artisan CLI, queues

PHP Frameworks: Laravel & Symfony

Laravel — Key Concepts

Laravel is the most popular PHP framework. It follows MVC, ships with Eloquent ORM, Artisan CLI, queues, broadcasting, and a rich ecosystem.

# Create project
composer create-project laravel/laravel myapp
cd myapp && php artisan serve

# Artisan commands
php artisan make:model Article -mcr    # model + migration + controller + resource
php artisan make:controller ArticleController --resource
php artisan make:middleware EnsureIsAdmin
php artisan make:job SendWelcomeEmail
php artisan make:event OrderPlaced
php artisan make:listener SendOrderConfirmation --event=OrderPlaced
php artisan make:mail WelcomeMail --markdown
php artisan make:request StoreArticleRequest
php artisan make:policy ArticlePolicy --model=Article

php artisan migrate
php artisan migrate:fresh --seed
php artisan db:seed
php artisan tinker          # REPL
<?php
// Routes (routes/web.php or routes/api.php)
Route::get('/articles', [ArticleController::class, 'index']);
Route::apiResource('articles', ArticleController::class);  // all CRUD routes
Route::middleware(['auth', 'verified'])->group(function () {
    Route::get('/dashboard', [DashboardController::class, 'index']);
});

// Eloquent ORM
class Article extends Model
{
    protected $fillable = ['title', 'content', 'user_id'];
    protected $casts = ['published_at' => 'datetime', 'is_featured' => 'boolean'];

    public function user(): BelongsTo { return $this->belongsTo(User::class); }
    public function tags(): BelongsToMany { return $this->belongsToMany(Tag::class); }

    public function scopePublished(Builder $query): Builder {
        return $query->whereNotNull('published_at')->where('published_at', '<=', now());
    }
}

// Queries
Article::all();
Article::find(1);
Article::where('user_id', $userId)->latest()->paginate(15);
Article::published()->with('user', 'tags')->get();  // eager load
Article::create(['title' => 'New', 'content' => '...', 'user_id' => auth()->id()]);
$article->update(['title' => 'Updated']);
$article->delete();

// Form Request validation
class StoreArticleRequest extends FormRequest {
    public function rules(): array {
        return [
            'title'   => ['required', 'string', 'min:5', 'max:200'],
            'content' => ['required', 'string', 'min:50'],
            'tags'    => ['array', 'exists:tags,id'],
        ];
    }
}

// Controller
class ArticleController extends Controller {
    public function store(StoreArticleRequest $request): JsonResponse {
        $article = Article::create($request->validated() + ['user_id' => auth()->id()]);
        $article->tags()->sync($request->tags ?? []);
        return response()->json($article->load('tags'), 201);
    }
}

Laravel — Queues, Events & Cache

<?php
// Jobs (queued background work)
class SendWelcomeEmail implements ShouldQueue {
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(private User $user) {}

    public function handle(Mailer $mailer): void {
        $mailer->to($this->user)->send(new WelcomeMail($this->user));
    }

    public function failed(\Throwable $e): void {
        Log::error("Failed to send welcome email to {$this->user->email}");
    }
}
// Dispatch: SendWelcomeEmail::dispatch($user)->delay(now()->addMinutes(5));

// Events & Listeners
class OrderPlaced {
    public function __construct(public readonly Order $order) {}
}
// Dispatch: event(new OrderPlaced($order));
// Listener: public function handle(OrderPlaced $event): void { ... }

// Cache
Cache::put('key', $value, ttl: 3600);
Cache::get('key', default: fn() => expensiveQuery());
Cache::remember('articles', 600, fn() => Article::published()->get());
Cache::forget('key');
Cache::tags(['articles'])->remember('published', 600, fn() => ...);
Cache::tags(['articles'])->flush();

Symfony — Key Concepts

Symfony is a modular enterprise framework. Many Laravel internals are built on Symfony components. Symfony favors explicit configuration and is the foundation for Drupal, API Platform, and more.

# Create project
composer create-project symfony/skeleton myapp    # minimal
composer create-project symfony/webapp myapp      # full stack

# Symfony CLI
symfony new myapp
symfony server:start
symfony console make:controller ArticleController
symfony console make:entity Article
symfony console doctrine:migrations:diff
symfony console doctrine:migrations:migrate
symfony console cache:clear
<?php
// Controller
#[Route('/api/articles', name: 'article_')]
class ArticleController extends AbstractController
{
    public function __construct(
        private ArticleRepository $articles,
        private EntityManagerInterface $em,
    ) {}

    #[Route('', name: 'index', methods: ['GET'])]
    public function index(Request $request): JsonResponse
    {
        $page = $request->query->getInt('page', 1);
        $articles = $this->articles->findPublished($page);
        return $this->json($articles);
    }

    #[Route('/{id}', name: 'show', methods: ['GET'])]
    public function show(Article $article): JsonResponse     // ParamConverter auto-fetches by id
    {
        return $this->json($article, context: ['groups' => ['article:read']]);
    }

    #[Route('', name: 'create', methods: ['POST'])]
    #[IsGranted('ROLE_USER')]
    public function create(Request $request, ValidatorInterface $validator): JsonResponse
    {
        $article = $this->serializer->deserialize($request->getContent(), Article::class, 'json');
        $article->setAuthor($this->getUser());

        $errors = $validator->validate($article);
        if (count($errors) > 0) {
            return $this->json($errors, 422);
        }
        $this->em->persist($article);
        $this->em->flush();
        return $this->json($article, 201, context: ['groups' => 'article:read']);
    }
}

// Doctrine Entity
#[ORM\Entity(repositoryClass: ArticleRepository::class)]
#[ORM\HasLifecycleCallbacks]
class Article
{
    #[ORM\Id, ORM\GeneratedValue, ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 200)]
    #[Assert\NotBlank, Assert\Length(min: 5, max: 200)]
    private string $title = '';

    #[ORM\ManyToOne(inversedBy: 'articles')]
    private ?User $author = null;

    #[ORM\PrePersist]
    public function onPrePersist(): void {
        $this->createdAt = new \DateTimeImmutable();
    }
}

Laravel vs Symfony — When to Use

  • Laravel: rapid application development, startups, SaaS products, REST APIs. Convention over configuration — sensible defaults out of the box.

  • Symfony: enterprise applications, large teams, complex domain logic, when you need explicit control over every component.

  • API Platform (Symfony): if you need a full REST/GraphQL API with OpenAPI docs auto-generated — it reads Doctrine entities and generates everything.

  • Both use Composer, PSR standards, and PHPUnit. Skills transfer between them.

  • Livewire (Laravel) / Hotwire (Symfony): add dynamic UI without writing JavaScript — server-rendered reactive components.

  • Laravel Octane: run Laravel on Swoole or RoadRunner for persistent process model — 5-10x more throughput.

  • Symfony Messenger: framework-agnostic message bus for commands/events/queries — cleaner than Laravel Jobs for complex CQRS setups.

Keep your PHP knowledge sharp.

Save this stack to your personal DevRecall — add your own notes, track what you're learning, and share what you know with the community.

Get started — free forever