Extensions

PhpSpec has a lightweight extension system. Extensions are Composer packages that provide new formatters, matchers, commands, or event listeners. You install them with Composer and register the class in phpspec.yml.

Quick Start

  1. Install the package:
$ composer require --dev acme/phpspec-nyan-formatter
  1. Register it in phpspec.yml:
extensions:
  formatters:
    - Acme\PhpSpecNyanFormatter\NyanFormatter
  1. Use it:
$ vendor/bin/phpspec run --format nyan

That's it. Composer handles autoloading, PhpSpec handles instantiation.

Configuration

All extensions are registered under the extensions key in phpspec.yml:

extensions:
  formatters:
    - Acme\PhpSpecNyanFormatter\NyanFormatter
    - Acme\PhpSpecGithub\GithubActionsFormatter
  matchers:
    - Acme\PhpSpecMatchers\ValidJsonMatcher
    - Acme\PhpSpecMatchers\ValidUuidMatcher
  commands:
    - Acme\PhpSpecLint\LintCommand
  listeners:
    - Acme\PhpSpecProfiler\SlowTestReporter

Each entry is a fully-qualified class name. The class must implement the appropriate extension interface and be autoloadable via Composer.

Extension Types

Formatters

Custom output formatters for spec results.

<?php

namespace Acme\PhpSpecGithub;

use PhpSpec\Extensions\FormatterExtension;

class GithubActionsFormatter extends FormatterExtension
{
    public function getName(): string
    {
        return 'github';
    }

    public function formatPass(string $title): string
    {
        return "::notice ::$title";
    }

    public function formatFail(string $title, string $message): string
    {
        return "::error ::$title - $message";
    }

    public function formatPending(string $title): string
    {
        return "::warning ::$title (pending)";
    }

    public function formatError(string $title, string $message): string
    {
        return "::error ::$title - $message";
    }

    public function formatSummary(int $total, int $passed, int $failed, int $pending, int $errors): string
    {
        return sprintf('%d example%s (%d passed, %d failed, %d pending, %d errored)', $total, $total === 1 ? '' : 's', $passed, $failed, $pending, $errors);
    }
}

Use with --format github or set as default:

format: github
extensions:
  formatters:
    - Acme\PhpSpecGithub\GithubActionsFormatter

Matchers

Custom matchers extend PhpSpec's expect() vocabulary.

<?php

namespace Acme\PhpSpecMatchers;

use PhpSpec\Extensions\MatcherExtension;

class ValidJsonMatcher extends MatcherExtension
{
    public function getName(): string
    {
        return 'toBeValidJson';
    }

    public function match(mixed $actual, mixed ...$args): bool
    {
        if (!is_string($actual)) {
            return false;
        }

        json_decode($actual);
        return json_last_error() === JSON_ERROR_NONE;
    }

    public function failureMessage(mixed $actual): string
    {
        return "Expected valid JSON, got: " . substr((string) $actual, 0, 50);
    }

    public function negatedFailureMessage(mixed $actual): string
    {
        return "Expected invalid JSON, but it was valid";
    }
}

Use in specs:

it('returns valid JSON', function () {
    expect('{"name": "phpspec"}')->toBeValidJson();
    expect('not json')->not()->toBeValidJson();
});

Commands

Add new CLI commands.

<?php

namespace Acme\PhpSpecLint;

use PhpSpec\Extensions\CommandExtension;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class LintCommand extends CommandExtension
{
    public function getName(): string
    {
        return 'lint';
    }

    public function getDescription(): string
    {
        return 'Check spec files for common mistakes';
    }

    public function execute(InputInterface $input, OutputInterface $output): int
    {
        // Your lint logic here
        $output->writeln('All specs look good.');
        return 0;
    }
}

Run with:

$ vendor/bin/phpspec lint

Listeners

React to lifecycle events during a spec run.

<?php

namespace Acme\PhpSpecProfiler;

use PhpSpec\Extensions\ListenerExtension;

class SlowTestReporter extends ListenerExtension
{
    private array $slow = [];

    public function afterExample(string $title, float $duration): void
    {
        if ($duration > 0.5) {
            $this->slow[] = [$title, $duration];
        }
    }

    public function afterSuite(): void
    {
        if (empty($this->slow)) {
            return;
        }

        usort($this->slow, fn($a, $b) => $b[1] <=> $a[1]);
        echo "\nSlow examples:\n";
        foreach ($this->slow as [$title, $duration]) {
            printf("  %.3fs  %s\n", $duration, $title);
        }
    }
}

Available events:

EventArguments
beforeSuite()none
afterSuite()none
beforeSpec(string $title)Spec file title
afterSpec(string $title)Spec file title
beforeExample(string $title)Example title
afterExample(string $title, float $duration)Example title and duration in seconds
onPass(string $title)Passed example
onFail(string $title, string $message)Failed example with error message
onError(string $title, string $message)Errored example with exception message
onPending(string $title)Pending example
onSkipped(string $title)Skipped example

Browsers

The browser behind visit() and the other browser DSL functions is replaceable. Name a class implementing PhpSpec\Browser\Browser; it must map whatever it drives onto the canonical PhpSpec\Browser\Response, which keeps every response matcher working unchanged:

extensions:
  browser: Acme\BrowserKitBrowser

Unlike the other types the key is singular: exactly one browser drives the DSL. The config's choice beats auto-discovery, and two auto-discovered browsers with no config choice are refused by name. An extension cannot yet declare its own config keys (a kernel path, say); that hook does not exist.

AI Tools

tools classes implement PhpSpec\Extensions\ToolProviderInterface and return AI tools the pair assistant can call. Currently consumed by pair only.

extensions:
  tools:
    - Acme\DeployStatusTools

Auto-Discovery

Extensions can opt into auto-discovery so users don't need to add them to phpspec.yml manually. Add a phpspec key to your package's composer.json:

{
    "name": "acme/phpspec-nyan-formatter",
    "extra": {
        "phpspec": {
            "formatters": ["Acme\\PhpSpecNyanFormatter\\NyanFormatter"]
        }
    }
}

When the package is installed, PhpSpec reads the extra.phpspec metadata from all installed packages and registers the extensions automatically.

To disable auto-discovery for a specific package:

extensions:
  disabled:
    - acme/phpspec-nyan-formatter

Writing an Extension Package

A minimal extension package has three files:

acme/phpspec-nyan-formatter/
  composer.json
  src/
    NyanFormatter.php

composer.json:

{
    "name": "acme/phpspec-nyan-formatter",
    "description": "Nyan cat formatter for PhpSpec",
    "type": "phpspec-extension",
    "require": {
        "php": "^8.3",
        "phpspec/phpspec": "^9.0"
    },
    "autoload": {
        "psr-4": {
            "Acme\\PhpSpecNyanFormatter\\": "src/"
        }
    },
    "extra": {
        "phpspec": {
            "formatters": ["Acme\\PhpSpecNyanFormatter\\NyanFormatter"]
        }
    }
}

src/NyanFormatter.php:

<?php

namespace Acme\PhpSpecNyanFormatter;

use PhpSpec\Extensions\FormatterExtension;

class NyanFormatter extends FormatterExtension
{
    public function getName(): string
    {
        return 'nyan';
    }

    public function formatPass(string $title): string
    {
        return "~=[,,_,,]:3 $title";
    }

    public function formatFail(string $title, string $message): string
    {
        return "=[,,_,,]= $title\n  $message";
    }
}

Publish to Packagist, and users get it with:

$ composer require --dev acme/phpspec-nyan-formatter
$ vendor/bin/phpspec run --format nyan

Extension Interfaces

Each extension type has a base class with the minimum contract:

TypeBase ClassRequired Methods
FormatterFormatterExtensiongetName(), formatPass(), formatFail()
MatcherMatcherExtensiongetName(), match(), failureMessage()
CommandCommandExtensiongetName(), execute()
ListenerListenerExtensionAt least one event method

All base classes live in PhpSpec\Extensions\.

Example: Full Project Config

spec_path: spec
src_path: src

format: pretty

extensions:
  formatters:
    - Acme\PhpSpecGithub\GithubActionsFormatter
  matchers:
    - Acme\PhpSpecMatchers\ValidJsonMatcher
    - Acme\PhpSpecMatchers\ValidUuidMatcher
  commands:
    - Acme\PhpSpecLint\LintCommand
  listeners:
    - Acme\PhpSpecProfiler\SlowTestReporter

Design Principles

  • Composer-native. Extensions are regular Composer packages with PSR-4 autoloading. No custom loaders, no file paths in config.
  • Config-driven. Registration happens in phpspec.yml by FQCN. Auto-discovery via composer.json extra is optional.
  • Minimal interfaces. Base classes have sensible defaults. Override only what you need.
  • No magic. Extensions don't modify PhpSpec internals. They plug into defined extension points: formatters, matchers, commands, and event listeners.