Best Practices & Tooling

PHP · Lesson 49 of 50 8 min read Updated

Code that works today is only half the job. Professional code is also easy to read, safe to change and checked by tools before it reaches users. The PHP community has agreed on a shared coding style, and it has excellent free tools that find bugs and run tests for you.

In this lesson you will learn the standard style rules, run the PHPStan analyser, write and run PHPUnit tests, tie the tools together with Composer scripts, and keep settings such as passwords out of your code with environment configuration.

A shared coding style: PSR-12 and PER

PSR-12 is a coding style guide published by PHP-FIG, a group of PHP project maintainers. Its successor, the PER Coding Style, keeps the same rules and extends them to newer syntax such as enums and property hooks. When everyone uses the same style, you can read any project as easily as your own. The main rules:

  • Four spaces for indentation, never tabs.
  • Opening braces of classes and methods on their own line; braces of if, foreach and other control structures on the same line.
  • One class per file, with the namespace and use lines at the top.
  • declare(strict_types=1); right after <?php in every file.
  • Visibility (public, private, protected) on every property, method and constant.
  • No closing ?> tag in files that contain only PHP.
PHP
<?php

declare(strict_types=1);

namespace App\Billing;

final class Invoice
{
    private const int GST_PERCENT = 18;

    public function __construct(
        private readonly int $amountPaise,
    ) {
    }

    public function totalPaise(): int
    {
        if ($this->amountPaise <= 0) {
            return 0;
        }

        return intdiv($this->amountPaise * (100 + self::GST_PERCENT), 100);
    }
}

You do not need to memorise every rule. PHP_CodeSniffer checks them for you, and PHP-CS-Fixer (with its PER-CS rule set) or PHP_CodeSniffer's phpcbf command fixes most problems automatically:

Terminal
composer require --dev squizlabs/php_codesniffer
vendor/bin/phpcs --standard=PSR12 src tests
vendor/bin/phpcbf --standard=PSR12 src tests

--dev puts a package in the require-dev section of composer.json. These are tools for developers, and production servers skip them with composer install --no-dev.

Static analysis with PHPStan

A static analyser reads your code without running it and reports bugs: calling a method that does not exist, passing a string where an int is expected, or using a value that might be null. PHPStan is the most popular one.

Terminal
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src tests --level 5

Imagine findStudent() returns ?Student (a student or null) and the next line does echo $student->name;. Your quick test may pass because the student exists, but PHPStan reports that you are reading a property on a value that may be null, before a real user hits the bug.

Levels start at 0 (the most relaxed) and go up to stricter checks. Start a new project at a medium level, raise it over time, and keep the settings in a phpstan.neon file so everyone runs the same checks:

Text
parameters:
    level: 6
    paths:
        - src
        - tests

With that file in place, vendor/bin/phpstan analyse needs no arguments. Type declarations on every parameter, property and return value make PHPStan far more useful, which is one more reason to write them.

Automated tests with PHPUnit

A unit test is a small piece of code that calls your code with known inputs and checks the result. Once written, tests run in seconds every time you change something, so you notice immediately if a change breaks old behaviour. PHPUnit is the standard test framework.

Here is a class to test:

PHP
<?php
declare(strict_types=1);

namespace App;

use InvalidArgumentException;

final class PriceCalculator
{
    public function withGst(int $paise, int $ratePercent = 18): int
    {
        if ($paise < 0) {
            throw new InvalidArgumentException('Price cannot be negative.');
        }
        return intdiv($paise * (100 + $ratePercent), 100);
    }
}

Tests live in a tests folder. Each test class extends TestCase, and each public method whose name starts with test is one test:

PHP
<?php
declare(strict_types=1);

namespace App\Tests;

use App\PriceCalculator;
use InvalidArgumentException;
use PHPUnit\Framework\TestCase;

final class PriceCalculatorTest extends TestCase
{
    public function testAddsEighteenPercentByDefault(): void
    {
        $calculator = new PriceCalculator();

        $this->assertSame(118000, $calculator->withGst(100000));
    }

    public function testUsesTheGivenRate(): void
    {
        $calculator = new PriceCalculator();

        $this->assertSame(105000, $calculator->withGst(100000, 5));
    }

    public function testRejectsNegativePrices(): void
    {
        $this->expectException(InvalidArgumentException::class);

        (new PriceCalculator())->withGst(-1);
    }
}

Install PHPUnit and run the tests:

Terminal
composer require --dev phpunit/phpunit
vendor/bin/phpunit tests
Output
PHPUnit 12.5.38 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.4.27

...                                                                 3 / 3 (100%)

Time: 00:00.016, Memory: 16.00 MB

OK (3 tests, 3 assertions)

Each dot is a passing test. A failing test prints an F and shows the expected and actual values. assertSame() checks both value and type, so 118000 and '118000' do not count as equal. Add "App\\Tests\\": "tests/" to the autoload-dev section of composer.json so the test classes are autoloaded. Good tests check one behaviour each and have names that say what should happen.

Composer scripts: one command for every check

Typing long tool commands gets tedious. Add them to composer.json as scripts:

JSON
{
    "scripts": {
        "lint": "phpcs --standard=PSR12 src tests",
        "analyse": "phpstan analyse",
        "test": "phpunit tests",
        "check": ["@lint", "@analyse", "@test"]
    }
}

Now composer test runs the tests and composer check runs all three tools in order. Composer adds vendor/bin to the path for scripts, so you do not need to write it. Run composer check before every commit; many teams also run it automatically on every push with a CI (continuous integration) service.

Environment configuration

The same code runs on your laptop, a test server and the live server, but each needs different settings: database passwords, API keys, whether to show errors. Never hard-code these values, and never commit secrets to Git. Read them from the environment instead:

PHP
<?php
// config/app.php
declare(strict_types=1);

return [
    'env' => getenv('APP_ENV') ?: 'production',
    'debug' => getenv('APP_DEBUG') === '1',
    'db' => [
        'host' => getenv('DB_HOST') ?: '127.0.0.1',
        'name' => getenv('DB_NAME') ?: 'school',
        'user' => getenv('DB_USER') ?: 'school_app',
        'pass' => getenv('DB_PASS') ?: '',
    ],
];

getenv() returns false when a variable is not set, and ?: then uses the default. The default for env is production and debug is off unless someone switches it on, so a forgotten setting fails safe. Your entry script can then do ini_set('display_errors', $config['debug'] ? '1' : '0');.

Hosting control panels, Docker and web server configs can all set environment variables. On shared hosting without that option, use a config file outside the web root that is not committed, with a committed example file next to it, as in Connecting to MySQL with PDO. In development, libraries such as vlucas/phpdotenv can load variables from a .env file; add .env to .gitignore.

Everyday habits

  • Start every file with declare(strict_types=1); and add types to every parameter, property and return value.
  • Keep functions short and give them names that say what they do: calculateLateFee(), not doStuff().
  • Return early instead of nesting if blocks five levels deep.
  • Use === and !==, which compare type as well as value.
  • Never silence errors with @; fix the cause.
  • Commit small changes often with clear messages, and keep vendor/ and secrets out of Git.

Key points

  • Follow PSR-12 / PER Coding Style, and let PHP_CodeSniffer or PHP-CS-Fixer check and fix it.
  • PHPStan finds bugs without running the code; raise its level over time.
  • PHPUnit tests call your code with known inputs and check the results with assertions like assertSame().
  • Install developer tools with composer require --dev and wrap them in Composer scripts.
  • Read settings and secrets from the environment or an uncommitted config file, never from committed code.
  • In production: OPcache on, --no-dev installs, display_errors off.

Exercise

Add a method discounted(int $paise, int $percent): int to PriceCalculator that returns the price after a percentage discount (rounded down to whole paise) and throws an InvalidArgumentException if the percent is below 0 or above 100. Write two PHPUnit tests for it: one for a 10% discount on ₹1,299 (129900 paise) and one for an invalid percent.

Show solution
PHP
public function discounted(int $paise, int $percent): int
{
    if ($percent < 0 || $percent > 100) {
        throw new InvalidArgumentException('Discount must be between 0 and 100 percent.');
    }
    return intdiv($paise * (100 - $percent), 100);
}
PHP
public function testAppliesTenPercentDiscount(): void
{
    $this->assertSame(116910, (new PriceCalculator())->discounted(129900, 10));
}

public function testRejectsDiscountAboveHundred(): void
{
    $this->expectException(InvalidArgumentException::class);

    (new PriceCalculator())->discounted(129900, 150);
}

₹1,299 minus 10% is ₹1,169.10, which is 116910 paise. intdiv() keeps the result a whole number, and the second test passes only if the exception is thrown.

Quick Quiz

1. What does PHPStan do?

2. Why install PHPUnit with composer require --dev?

3. What is the difference between assertSame() and a loose comparison?

4. Where should a live site's database password come from?

Learn PHP with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call