Guide

PHP fundamentals explained

A regional news publisher runs dozens of city sites on a shared CMS. Editors upload articles, schedule publishes, and syndicate headlines to partner feeds — all through PHP request handlers behind nginx. That is not a relic of the 2000s: PHP still powers a large share of the public web, from WordPress and Drupal to Laravel and Symfony APIs. Modern PHP 8.x adds strict types, union and intersection types, attributes, enums, and a JIT compiler in the Zend engine. The language that earned a reputation for mysql_query spaghetti can now ship typed services with Composer autoloading, PHPUnit tests, and PDO prepared statements — if you adopt the patterns frameworks have standardized over the last decade. This guide covers the runtime and request lifecycle, syntax and type system, OOP and namespaces, Composer, database access, error handling, concurrency basics, a Harbor Media content API worked example, a language decision table, common pitfalls, and a production checklist — alongside our Python, Node.js, and Java language guides.

What PHP is: Zend, FPM, and the request lifecycle

PHP is a server-side scripting language designed for HTML generation and HTTP workloads. Source files (.php) are parsed and executed by the Zend engine on each request (unless opcode caching is enabled). In production, PHP-FPM (FastCGI Process Manager) runs worker pools behind nginx or Apache; each worker handles one request at a time, then resets state.

A typical flow: nginx receives GET /articles/slug, forwards to FPM via a Unix socket, PHP bootstraps autoloaders, runs your front controller (public/index.php in Laravel/Symfony), dispatches to a route handler, queries the database, renders JSON or HTML, and returns. Stateless handlers scale horizontally by adding FPM workers and app servers behind a load balancer.

PHP 8.x features that matter

  • JIT compiler — optional opcode JIT for CPU-heavy loops; most web apps benefit more from OPcache than JIT tuning.
  • Named argumentscreateArticle(title: 'Ship log', status: 'draft') improves readability of wide constructors.
  • Match expressions — exhaustive branching without fall-through bugs of legacy switch.
  • Attributes#[Route('/api/articles')] metadata replaces docblock annotations in modern frameworks.
  • Enums and readonly classes — model domain states without string constants scattered across files.

Pin your production version to a supported release (8.2 or 8.3 as of 2026). PHP 7.x is end-of-life; running it exposes unpatched security holes on any internet-facing host.

Syntax, types, and the array-centric data model

PHP variables start with $ and are loosely typed by default. Enable strict types at the top of each file for production code:

<?php
declare(strict_types=1);

function slugify(string $title): string {
    return strtolower(preg_replace('/[^a-z0-9]+/i', '-', $title));
}

Scalar types include int, float, string, bool, and null. PHP 8 adds mixed, union types (int|string), and intersection types (Countable&Iterator). Return type declarations and parameter types catch bugs at call sites instead of deep in business logic.

Arrays: lists, maps, and gotchas

PHP arrays are ordered hash maps that double as lists and dictionaries: ['id' => 42, 'title' => 'Harbor briefing']. They are copy-on-write and passed by value unless you prefix with & for references. For typed collections in larger codebases, prefer value objects or DTO classes over nested array shapes that IDEs cannot validate.

The null coalescing operator (??) and nullsafe operator (?->) reduce boilerplate when reading optional nested fields from API payloads or ORM results.

Object-oriented PHP: classes, interfaces, and traits

Modern PHP is fully object-oriented. Classes support visibility modifiers (public, protected, private), abstract methods, interfaces, and traits for horizontal reuse without deep inheritance trees.

readonly class ArticleDraft {
    public function __construct(
        public string $id,
        public string $title,
        public ArticleStatus $status,
    ) {}
}

enum ArticleStatus: string {
    case Draft = 'draft';
    case Published = 'published';
    case Archived = 'archived';
}

Readonly classes (PHP 8.2+) freeze properties after construction — ideal for immutable domain records passed between layers. Enums replace magic strings for status fields and make match expressions exhaustive.

Namespaces and autoloading

Namespaces (namespace Harbor\Media;) prevent class name collisions and map to directory structure via PSR-4 autoloading. Never rely on require_once chains in application code; Composer generates an autoloader that loads classes on first use.

Dependency injection is standard in Laravel and Symfony: type-hint interfaces in constructors, bind implementations in a service container, and let the framework resolve graphs. Even in plain PHP, manual constructor injection keeps tests fast (swap repositories with in-memory fakes).

Composer: dependencies, scripts, and project layout

Composer is PHP’s package manager. composer.json declares dependencies; composer.lock pins exact versions for reproducible deploys. Run composer install --no-dev --optimize-autoloader in production images; commit the lockfile to version control.

  • PSR-4 autoload — map Harbor\\Media\\ to src/ so class files load automatically.
  • Scripts — hook post-install-cmd for cache warmup or migration checks in CI.
  • Platform config — set "platform": {"php": "8.3.0"} so local Composer resolves compatible package versions.
  • Security — run composer audit in CI; subscribe to advisories for Symfony, Laravel, and Guzzle.

A sensible layout separates public/ (web root with only index.php and assets) from src/ (application code), config/, tests/, and var/ (cache and logs outside the web root). Never expose vendor/ or .env files directly to HTTP.

Database access with PDO

PDO (PHP Data Objects) is the portable database abstraction layer in the standard library. Always use prepared statements with bound parameters — never interpolate user input into SQL strings.

$stmt = $pdo->prepare(
    'SELECT id, title, published_at FROM articles WHERE slug = :slug LIMIT 1'
);
$stmt->execute(['slug' => $slug]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);

Configure PDO with ERRMODE_EXCEPTION so failed queries throw catchable exceptions instead of returning false silently. For larger apps, ORMs (Eloquent in Laravel, Doctrine in Symfony) add migrations, relationship mapping, and query builders — trade some complexity for productivity on CRUD-heavy domains.

Connection pooling in PHP is per-request: open late, close early, or reuse a single PDO instance per worker. For read-heavy APIs, route analytics queries to a PostgreSQL replica; keep writes on the primary with explicit transaction boundaries.

Error handling, logging, and debugging

PHP distinguishes errors (engine-level issues) from exceptions (throwable objects). In production, set display_errors=Off and log to structured files or syslog. Use try/catch around I/O boundaries (database, HTTP clients, filesystem) and map low-level failures to domain exceptions with context.

try {
    $article = $repository->findBySlug($slug);
} catch (PDOException $e) {
    $logger->error('article lookup failed', ['slug' => $slug, 'err' => $e->getMessage()]);
    throw new ArticleNotFoundException($slug, previous: $e);
}

Frameworks provide exception handlers that convert uncaught errors to JSON problem details or HTML error pages. Register a global handler in plain PHP with set_exception_handler and register_shutdown_function for fatal errors. Never return stack traces to public API clients.

Local debugging uses Xdebug step debugging or Ray/spatie packages; production relies on correlation IDs in logs and APM tools (Blackfire, Tideways) for slow query traces.

HTTP, routing, and framework landscape

Raw PHP can serve APIs with $_SERVER, $_GET, and file_get_contents('php://input'), but frameworks eliminate boilerplate:

  • Laravel — batteries-included: routing, Eloquent ORM, queues, Horizon, Sanctum auth. Best for greenfield APIs and admin portals.
  • Symfony — component-based; HTTP kernel, Messenger, Validator. Strong for enterprise apps needing long-term flexibility.
  • WordPress — hook-driven CMS; extend with themes/plugins. Know when you are building content sites vs custom APIs.

For JSON APIs, return consistent envelopes, validate input with Symfony Validator or Laravel Form Requests, and document routes with OpenAPI. Follow our REST API design guide for status codes, pagination, and idempotency keys on write endpoints.

Compare framework depth in our Django and Flask guides when choosing between PHP and Python for a new backend.

Concurrency, queues, and background work

PHP-FPM workers are single-threaded per request. Long tasks (image resizing, PDF generation, bulk email) should not block HTTP responses. Offload to:

  • Queue workers — Laravel Horizon or Symfony Messenger consumers process jobs from Redis or RabbitMQ.
  • Cron — scheduled php artisan schedule:run or systemd timers for nightly syndication pulls.
  • ReactPHP / Swoole / FrankenPHP — async and long-lived workers for WebSockets or high-concurrency edge cases; operational complexity rises.

Design handlers to finish within your nginx fastcgi_read_timeout. If p95 latency exceeds two seconds, split read and write paths or cache hot article lists in Redis with explicit TTL and cache-bust on publish events.

Worked example: Harbor Media content API

Harbor Media syndicates articles to partner sites. Editors need a JSON API to create drafts, schedule publishes, and emit webhooks when stories go live.

  1. Domain layerArticleDraft readonly class and ArticleStatus enum; ArticleRepository interface with save, findBySlug, and listPublished methods.
  2. Persistence — PDO repository against PostgreSQL with a slug unique index and published_at timestamptz column stored in UTC.
  3. HTTP layer — front controller routes POST /api/v1/articles and GET /api/v1/articles/{slug}; validate JSON body with typed DTOs; return 201 with Location header on create.
  4. Auth — Bearer token per partner, hashed in database; rate-limit by API key at nginx or middleware.
  5. Webhooks — on publish, enqueue a Messenger job that POSTs signed payloads to subscriber URLs with retry and dead-letter queue.
  6. Deploy — PHP 8.3 FPM image in Docker, nginx terminating TLS, OPcache enabled, composer install --no-dev, migrations run once per release, horizontal scale behind a load balancer with shared Redis for sessions and queues.

Result: a typed, testable API that PHPUnit covers at the repository and HTTP layers, survives traffic spikes during breaking news, and keeps WordPress or static frontends decoupled from syndication logic.

Language decision table

NeedPrefer PHPConsider instead
WordPress or Drupal CMS siteYes — native ecosystemHeadless CMS + static site for simpler scale
Laravel/Symfony JSON API with hiring poolYes — mature packages and docsDjango or Node.js if team already standardized
Shared hosting with only PHP availableYes — lowest friction deployVPS with Docker for any language
CPU-heavy data science or MLNoPython
Real-time WebSocket game serverRarely — Swoole/FrankenPHP add ops costNode.js, Go, or dedicated game backend
Android or JVM enterprise microservicesNoJava / Kotlin
Single static binary CLI toolNoGo or Rust
Existing PHP monolith with Composer and testsYes — incremental modernization beats rewriteStrangler pattern to extract hot paths only when metrics justify

Common pitfalls

  • SQL injection — string-concatenated queries with user input; always bind parameters through PDO or the ORM.
  • Missing strict_types — silent coercion turns "0" into surprising truthiness bugs in payment and inventory code.
  • Exposing vendor/ and .env — misconfigured nginx document roots leak secrets; web root must be public/ only.
  • Global state — mutating $_GLOBALS or static properties makes tests order-dependent; inject dependencies.
  • N+1 queries — looping ORM results and lazy-loading relations; eager-load with with() or JOIN queries.
  • Opcode cache disabled — OPcache off in production wastes CPU re-parsing identical files every request.
  • Running end-of-life PHP — PHP 7.x on the public internet is an open CVE buffet.
  • Blocking the worker — synchronous cURL to slow third parties inside HTTP handlers; queue outbound calls.
  • serialize() for untrusted data — object injection vulnerabilities; use JSON for caches and cookies.

Production checklist

  • Pin PHP 8.2+ in Docker, CI, and hosting; enable OPcache with validated opcache.validate_timestamps=0 in immutable deploys.
  • Composer lockfile committed; composer audit in CI on every pull request.
  • Web root limited to public/; secrets in environment variables, not committed .env.
  • PDO ERRMODE_EXCEPTION; database migrations versioned and run once per release.
  • PHPUnit (or Pest) unit tests plus HTTP feature tests for critical API routes.
  • Structured JSON logs with request IDs; centralize via Loki or CloudWatch.
  • Rate limiting and request size caps at nginx; validate Content-Type on JSON endpoints.
  • Queue workers supervised by systemd or Kubernetes; alert on queue depth growth.
  • Health check endpoint (/healthz) that verifies database connectivity.
  • Load test publish and webhook paths before major news events; profile slow queries with EXPLAIN.

Key takeaways

  • Modern PHP 8.x is a typed, object-oriented language with enums, attributes, and readonly classes — not the procedural scripts of PHP 4.
  • Composer and PSR-4 autoloading are mandatory for maintainable projects; commit lockfiles and audit dependencies.
  • PDO prepared statements and framework validation layers prevent the injection bugs that tarnished PHP’s reputation.
  • PHP-FPM scales stateless HTTP handlers; offload slow work to queues instead of blocking workers.
  • Choose PHP when CMS ecosystems, shared hosting, or an existing Laravel/Symfony codebase are the constraint; prefer other runtimes for ML, real-time games, or greenfield teams standardized elsewhere.

Related reading