PHPBest Practices

PHP Best Practices

Writing PHP that runs is easy. Writing PHP that a team can still understand, extend, and safely change eight months later is a different skill. This page pulls together habits that don't map to a single feature or syntax rule — they're judgment calls about how you structure code, name things, and organize a project so that it stays maintainable as it grows. None of these are enforced by the PHP interpreter; they're conventions the wider PHP community has converged on because the alternative gets painful at scale.

Follow a shared style guide (PSR-12)

PSR-12 is a community-maintained coding style standard: four-space indentation, one class per file, braces on their own line for classes and methods, a consistent order for declare, namespace, and use statements, and so on. None of these choices are objectively "better" than reasonable alternatives — the value is in everyone on a team (and across the wider PHP ecosystem) using the same one, so that a file looks the same regardless of who wrote it. Most editors and CI setups can enforce this automatically with a tool like PHP-CS-Fixer or PHP_CodeSniffer, so nobody has to remember the rules by hand.

PSR-12 shape: braces, spacing, declare order

PHP
<?php

declare(strict_types=1);

namespace App\Billing;

use App\Billing\Contracts\InvoiceRepository;

final class InvoiceService
{
    public function __construct(
        private readonly InvoiceRepository $invoices,
    ) {
    }

    public function markPaid(int $invoiceId): void
    {
        $invoice = $this->invoices->find($invoiceId);
        $invoice->markPaid();
        $this->invoices->save($invoice);
    }
}
Name things for what they mean, not how they work

A variable named $d or a function named process() forces every future reader to open the code and reverse-engineer what it actually does. Names should describe intent: $daysUntilExpiry instead of $d, sendWelcomeEmail() instead of handle(). This costs a few extra seconds while typing and saves minutes (multiplied by every person who reads the code afterward, including you in six months). The same applies to boolean variables and functions — prefixing them with is, has, or should (isActive, hasPermission) makes their meaning obvious at the call site without checking the definition.

Turn on strict_types

By default PHP coerces types where it can — passing the string "5" to a function that types its parameter as int just silently converts it. Adding declare(strict_types=1) at the top of a file disables that coercion for calls made from that file: a type mismatch throws a TypeError immediately instead of being quietly converted, which surfaces bugs at the point they happen rather than several function calls downstream.

Strict types catches mismatches immediately

PHP
<?php
declare(strict_types=1);

function applyDiscount(float $price, int $percentOff): float
{
    return $price - ($price * $percentOff / 100);
}

applyDiscount(19.99, 10);     // fine
applyDiscount(19.99, "10");   // TypeError: must be of type int, string given
Keep functions small and single-purpose

A function that validates input, queries the database, formats a response, and sends an email is doing four jobs, which means it has four separate reasons to change and four separate ways to break. Splitting it into four smaller functions — each with one responsibility and a name that says exactly what it does — makes each piece independently readable, independently testable, and reusable somewhere else without dragging the other three responsibilities along with it. The same idea applies to classes: a class that has outgrown its single purpose and started absorbing unrelated behavior is usually a sign it should be split.

Watch for God classes and God functions
A class or function that keeps growing new responsibilities every time a feature is added is a "God object" — everything routes through it, so every change risks breaking something unrelated, and nobody can hold its full behavior in their head. If a class needs a paragraph to describe what it does, it's usually doing too much and should be split along its natural responsibilities.
Prefer dependency injection over global state

Reaching for a global variable, a static property, or a singleton inside a function is convenient in the moment, but it hides a dependency: nothing in the function's signature tells you it relies on that global, so tests have to fight to reset it and other code can change it out from under you. Passing dependencies in explicitly — through a constructor or function parameter — makes every dependency visible at the call site and lets tests substitute a fake version without needing to touch global state at all.

Global state vs. an injected dependency

PHP
<?php
// Hidden dependency — hard to test, hard to see at a glance
function sendNotification(string $message): void
{
    global $mailer;
    $mailer->send($message);
}

// Explicit dependency — visible, swappable, testable
final class NotificationSender
{
    public function __construct(private readonly Mailer $mailer)
    {
    }

    public function send(string $message): void
    {
        $this->mailer->send($message);
    }
}
Favor composition and interfaces over deep inheritance

A deep inheritance chain — a class extending a class extending another class — tends to become fragile: changing behavior in a base class can ripple unpredictably through every descendant, and understanding what a leaf class actually does means reading its entire ancestry. Composition (building a class out of smaller collaborator objects passed in through the constructor) and coding against interfaces instead of concrete classes usually age better: each piece is independently replaceable, and a class only depends on the contract it actually needs, not everything a parent class happens to provide.

Pin dependency versions deliberately

Composer's version constraints control how much a dependency is allowed to drift when someone runs composer update. A loose constraint like "^2.0" lets any 2.x release in, including one that quietly changes behavior your code relies on. Committing composer.lock ensures everyone on the team, and every deployment, installs the exact versions that were tested together, while an occasional deliberate composer update (reviewed, not automatic) is how you actually move forward.

composer.json version constraints

HTML
{
  "require": {
    "monolog/monolog": "^3.5",
    "guzzlehttp/guzzle": "^7.8"
  }
}
Write tests alongside the code, not after

It's tempting to treat tests as cleanup work done once a feature "works," but by that point you've already lost the moment when you understood the edge cases best. Writing a test right after (or before) writing the function that satisfies it means the edge cases — empty input, a negative number, a missing record — are still fresh in your mind, and the test becomes a safety net for every future change to that function rather than a chore retrofitted months later, if it happens at all.

  • Follow PSR-12 (or your team's agreed style) and let a formatter enforce it automatically.

  • Name variables, functions, and classes for what they represent, not how they happen to be implemented.

  • declare(strict_types=1) at the top of new files to catch type mismatches immediately.

  • Keep functions and classes focused on one responsibility each.

  • Inject dependencies explicitly instead of reaching for globals or singletons.

  • Compose behavior from small collaborators and code against interfaces rather than building deep inheritance chains.

  • Commit composer.lock and update dependencies deliberately, not accidentally.

  • Write the test next to the code, while the edge cases are still fresh.

Tip
None of these practices matter in isolation on a five-line script. They earn their keep as a codebase grows past what one person can hold in their head at once — which, for most real projects, happens sooner than expected.