<< Home

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.

💡 Keep a fast local feedback loop: run a focused test, fix the line, run again. Speed encourages frequent checks and raises overall quality.

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
⚠️ Keep a separate production config where 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/...
💡 Run tests with --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.

DoublePurposeTypical use
StubReturn fixed valuesService returns predictable data
MockVerify interactionsEnsure a notifier is called once
SpyRecord calls for laterCapture parameters without strict setup
FakeIn-memory alternativeIn-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; }
}
⚠️ Do not over-mock. When too many interactions are asserted, refactoring becomes painful and tests stop describing behavior.

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);
}
💡 Raise analysis levels one notch after each cleanup. Small steady steps keep the team moving without friction.

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);
⚠️ Do not run multiple formatters on the same files in the same step. Pick one tool for rewriting and one for checking to avoid conflicts.

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.

💡 Add a short pre-push hook that runs a subset of checks locally. Fast feedback avoids wasting CI minutes and reduces iteration cycles.

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.