Best Practices & Tooling
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,foreachand other control structures on the same line. - One class per file, with the namespace and
uselines at the top. declare(strict_types=1);right after<?phpin every file.- Visibility (
public,private,protected) on every property, method and constant. - No closing
?>tag in files that contain only 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:
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.
composer require --dev phpstan/phpstan
vendor/bin/phpstan analyse src tests --level 5Imagine 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:
parameters:
level: 6
paths:
- src
- testsWith 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
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
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:
composer require --dev phpunit/phpunit
vendor/bin/phpunit testsPHPUnit 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:
{
"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
// 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(), notdoStuff(). - Return early instead of nesting
ifblocks 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 --devand 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-devinstalls,display_errorsoff.
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
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);
}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?
PHPStan is a static analyser; formatting is the job of PHP_CodeSniffer or PHP-CS-Fixer.
2. Why install PHPUnit with composer require --dev?
Packages in require-dev are needed for development, not for running the live site.
3. What is the difference between assertSame() and a loose comparison?
assertSame(118000, '118000') fails, because one is an int and the other a string.
4. Where should a live site's database password come from?
Secrets must stay out of committed code and out of the web root.
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
