Chapter 18: Testing, Debugging, and Quality
Quality comes from short feedback loops and repeatable checks. You will use Xdebug for interactive debugging, configure helpful php.ini options, write unit tests with PHPUnit, isolate collaborators with test doubles, catch issues earlier using static analysis, enforce a consistent style with PSR-12, and automate everything in continuous integration.
Xdebug and Useful php.ini Settings
Xdebug adds step debugging, stack traces, code coverage, and better error information. Pair it with strict runtime settings to catch problems early. Configure it per environment so development has rich detail while production stays minimal and safe.
Installing and enabling Xdebug
Install the extension and enable it in your php.ini or a dedicated ini file. Verify with php -v or php -m.
; xdebug.ini
zend_extension=xdebug
xdebug.mode=develop,debug,coverage
xdebug.start_with_request=yes
xdebug.discover_client_host=true
xdebug.client_port=9003
; For CLI scripts you can keep it manual:
; xdebug.start_with_request=trigger
Connecting your IDE and debugging requests
Modern IDEs listen on port 9003. Use a browser extension or query string trigger to start a session. For CLI, export variables before running commands.
# CLI one-off debug session
XDEBUG_MODE=debug XDEBUG_CONFIG="client_host=127.0.0.1" php script.php
Strengthening php.ini for development safety
Turn on strict reporting, show errors in development only, and use sane resource limits. In production send errors to logs and hide details.
; Development ini snippets
error_reporting=E_ALL
display_errors=1
display_startup_errors=1
log_errors=1
memory_limit=512M
zend.assertions=1
assert.exception=1
display_errors=0 and sensitive details are never shown to end users.
PHPUnit and Test Structure
PHPUnit is the standard unit testing framework for PHP. Organize tests to mirror your src/ tree, keep tests independent, and run them in isolation. Favour small deterministic tests and name them clearly so failures point at intent.
Adding phpunit to the project
Install as a development dependency and create a basic configuration file. Composer scripts make the command short to type.
# composer.json (excerpt)
{
"require-dev": {
"phpunit/phpunit": "^11.0"
},
"scripts": {
"test": "phpunit --colors=always"
}
}
<!-- phpunit.xml -->
<phpunit colors="true" bootstrap="vendor/autoload.php">
<testsuites>
<testsuite name="unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<coverage cacheDirectory="var/.coverage" processUncoveredFiles="true">
<include><directory suffix=".php">src</directory></include>
</coverage>
</phpunit>
Writing a simple unit test
Create tests that express behavior. Arrange the inputs, act by calling the method, assert the outcome. Keep one expectation per logical behavior.
// tests/Unit/PriceCalculatorTest.php
use PHPUnit\Framework\TestCase;
use App\Domain\PriceCalculator;
final class PriceCalculatorTest extends TestCase
{
public function testCalculatingWithTax(): void
{
$calc = new PriceCalculator(0.2);
$total = $calc->total(100.00);
$this->assertSame(120.00, $total);
}
}
Structuring the test directories
Mirror src/ for discoverability and faster navigation. Group helpers in tests/Support and reuse builders or fixtures carefully.
project/
src/
Domain/PriceCalculator.php
tests/
Unit/PriceCalculatorTest.php
Integration/...
Support/Builders/...
--testdox to get readable behavior summaries that double as living documentation.
Mocking and Test Doubles
Test doubles replace real collaborators during tests. Use them to isolate the unit and to model error paths. Choose the lightest double that satisfies your scenario so tests stay clear and maintainable.
Choosing among stubs, mocks, spies, and fakes
Each double solves a different need. A stub returns canned data. A mock asserts that calls happen. A spy records calls for later assertions. A fake is a lightweight working implementation that is suitable for tests.
| Double | Purpose | Typical use |
| Stub | Return fixed values | Service returns predictable data |
| Mock | Verify interactions | Ensure a notifier is called once |
| Spy | Record calls for later | Capture parameters without strict setup |
| Fake | In-memory alternative | In-memory repository instead of a database |
Creating mocks with PHPUnit
Use createMock() to configure behavior and expectations. Keep interaction assertions minimal and focused on observable effects.
$mailer = $this->createMock(App\Service\Mailer::class);
$mailer->expects($this->once())
->method('send')
->with($this->equalTo('user@example.com'), $this->stringContains('Welcome'));
$service = new App\Service\SignupService($mailer);
$service->signup('user@example.com');
Using fakes to speed up integration tests
Replace slow external systems with in-memory fakes for repeatable tests. Keep interfaces identical so production code swaps implementations without changes.
final class InMemoryUserRepo implements UserRepo
{
/** @var array<int,User> */
private array $items = [];
public function save(User $user): void { $this->items[$user->id] = $user; }
public function find(int $id): ?User { return $this->items[$id] ?? null; }
}
Static Analysis with PHPStan and Psalm
Static analyzers reason about code without running it. They catch missing types, dead code, incorrect calls, and impossible paths. Start at a lenient level then raise strictness as the codebase improves.
Adding phpstan with a minimal config
Install the tool, configure paths, and set an initial level. Level 0 is loose while higher levels are stricter.
# composer.json (excerpt)
{
"require-dev": {
"phpstan/phpstan": "^1.11"
},
"scripts": {
"stan": "phpstan analyse --no-progress"
}
}
# phpstan.neon
parameters:
level: 5
paths:
- src
tmpDir: var/.phpstan
Initializing psalm for deeper checks
Psalm can complement PHPStan or run alone. Initialize it to generate a baseline and then remove entries as you fix issues.
# composer require --dev vimeo/psalm
vendor/bin/psalm --init
vendor/bin/psalm --no-cache
Adding precise types and suppressing safely
Prefer native type hints and return types. If a false positive blocks progress, document the intent and add a narrow suppression, then schedule removal.
/** @param array<string,int> $map */
function total(array $map): int {
return array_sum($map);
}
Code Style and PSR-12
Consistent formatting reduces cognitive load and noise in diffs. Adopt PSR-12 and enforce it with a tool so style debates end quickly and results stay uniform.
Checking style with phpcs
PHP_CodeSniffer verifies files against coding standards. Add a Composer script and fail the build when violations occur.
# composer.json (excerpt)
{
"require-dev": {
"squizlabs/php_codesniffer": "^3.10"
},
"scripts": {
"cs": "phpcs --standard=PSR12 src",
"cs:fix": "phpcbf --standard=PSR12 src"
}
}
Formatting with PHP-CS-Fixer
For auto-formatting, use friendsofphp/php-cs-fixer. Configure rules close to PSR-12 and let the tool rewrite files.
// .php-cs-fixer.php
<?php
$finder = PhpCsFixer\Finder::create()->in(['src', 'tests']);
return (new PhpCsFixer\Config())
->setRiskyAllowed(true)
->setRules([
'@PSR12' => true,
'array_syntax' => ['syntax' => 'short'],
'ordered_imports' => true,
])
->setFinder($finder);
CI Basics for PHP Projects
Continuous integration runs your checks on every push. Keep the pipeline simple: install dependencies, cache Composer, run static analysis, run tests, and upload coverage. A short pipeline encourages frequent commits and faster review.
GitHub Actions workflow with matrix builds
Use a small workflow that tests multiple PHP versions, caches dependencies, and runs your quality tools. Keep steps explicit so failures are obvious.
# .github/workflows/ci.yml
name: ci
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
strategy:
matrix:
php: ['8.2', '8.3', '8.4']
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
coverage: xdebug
tools: composer:v2
- name: Cache Composer
uses: actions/cache@v4
with:
path: vendor
key: composer-${{ hashFiles('**/composer.lock') }}
- run: composer install --no-interaction --prefer-dist
- run: composer run stan
- run: composer run cs
- run: composer run test
- name: Upload coverage artifact
if: always()
uses: actions/upload-artifact@v4
with:
name: coverage-${{ matrix.php }}
path: var/.coverage/**/*
GitLab CI and generic runners
The same pattern works on other platforms. Use official images, cache Composer, and run the same scripts so local and CI behavior match.
# .gitlab-ci.yml
stages: [build, test]
variables:
COMPOSER_CACHE_DIR: "$CI_PROJECT_DIR/.composer-cache"
cache:
paths:
- vendor/
- .composer-cache/
test:
image: php:8.3-cli
stage: test
before_script:
- apt-get update && apt-get install -y git unzip libzip-dev
- docker-php-ext-install zip
- curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer
- composer install --no-interaction --prefer-dist
script:
- composer run stan
- composer run cs
- composer run test
Failing fast and keeping pipelines healthy
Order steps so cheap checks fail first. Static analysis and style checks run before tests. Keep a stable cache key based on composer.lock for speed. Treat a failing CI status as a blocker for merges.
With a debugger, strict runtime settings, dependable tests, precise analysis, consistent style, and an automated pipeline, your project gains confidence and velocity. Small issues surface earlier and big defects become rare.