Skip to content

Proof of concept for PhpSpec 9#1511

Open
MarcelloDuarte wants to merge 10 commits intophpspec:mainfrom
MarcelloDuarte:phpspec-9-poc
Open

Proof of concept for PhpSpec 9#1511
MarcelloDuarte wants to merge 10 commits intophpspec:mainfrom
MarcelloDuarte:phpspec-9-poc

Conversation

@MarcelloDuarte
Copy link
Member

Summary

This is a proof of concept for PhpSpec 9 — a ground-up modernisation of the framework.

Why PhpSpec 9?

PhpSpec has served the PHP community well for over a decade, but the ecosystem has moved on:

  • PHP itself has evolved dramatically. Enums, fibers, readonly classes, first-class callables, named arguments — modern PHP enables patterns that weren't possible when PhpSpec was designed. The current architecture can't take full advantage of these.

  • The dependency surface has grown fragile. Maintaining compatibility across Prophecy, Doctrine Instantiator, Sebastian Exporter, four Symfony packages, and their cascading version matrices is an ever-growing maintenance burden. Every upstream release risks breakage. PhpSpec 9 reduces runtime dependencies from 9 packages to 2 (symfony/console + symfony/yaml).

  • The ObjectBehavior pattern creates friction. Developers must learn a DSL that maps $this->shouldReturn() to assertions and $this->beConstructedWith() to constructors. It's powerful but opaque. The describe/it/expect pattern (used by virtually every other testing framework across languages) is immediately familiar.

  • Story BDD shouldn't require a separate tool. Currently phpspec users need Behat for feature-level testing. PhpSpec 9 has built-in Gherkin support with given()/when()/then() step definitions, unifying Spec BDD and Story BDD in a single tool.

  • AI-assisted development is the new reality for BDD. AI coding assistants can generate classes, methods, and entire modules in seconds — but speed without direction is dangerous. An AI will happily produce code that looks correct but doesn't match the intended behaviour. This is exactly the problem BDD was designed to solve: a spec is a machine-readable contract that validates behaviour, whether a human or an AI writes the implementation. BDD becomes more important with AI, not less. PhpSpec 9 embraces this by making AI a first-class participant in the BDD cycle, not an afterthought.

What changes

Syntax — Jasmine/RSpec-style closures replace ObjectBehavior:

// PhpSpec 8
class CalculatorSpec extends ObjectBehavior
{
    function it_adds_numbers()
    {
        $this->add(2, 3)->shouldReturn(5);
    }
}

// PhpSpec 9
describe(Calculator::class, function () {
    it('adds numbers', function () {
        expect((new Calculator())->add(2, 3))->toBe(5);
    });
});

Mocking — Built-in, no Prophecy:

it('logs messages', function (Logger $logger) {   // auto-mocked
    allow($logger->info())->toReturn(true);
    // ... exercise SUT ...
    expect($logger->info())->toBeCalled()->once();
});

Story BDD — Built-in Gherkin support:

Feature: Calculator
  Scenario: Adding numbers
    Given I have a calculator
    When I add 2 and 3
    Then the result should be 5
given('I have a calculator', function () {
    $this->calculator = new Calculator();
});

Dependencies — Dramatically reduced:

PhpSpec 8 PhpSpec 9
Runtime packages 9 2
Prophecy Required Removed
Doctrine Instantiator Required Removed
Sebastian Exporter Required Removed
Symfony packages 5 (Console, EventDispatcher, Process, Finder, Yaml) 2 (Console, Yaml)

AI-powered pair programming

PhpSpec 9 includes three AI commands — pair, next, and refactor — that embed the AI inside the BDD cycle rather than running alongside it. All AI features are opt-in: they require a suggest dependency (papi-ai/papi-core + a provider package) and an ai: section in your config. Without them, PhpSpec works exactly as it does without AI — zero runtime cost, no API keys needed.

Why AI belongs in the BDD tool

AI assistants generate code fast, but they don't know what behaviour is correct. Specs do. By placing the AI inside PhpSpec, every generated class and method is immediately validated against specs. The BDD cycle becomes the contract that keeps both human and AI-generated code honest:

Feature scenario (failing)
  → AI generates step definitions
    → Spec (failing)
      → AI generates class/method
        → Spec (green) ← the spec decides what "correct" means
      → Step (green)
    → Scenario (green)

The AI accelerates the cycle; the specs guarantee correctness. Neither replaces the other.

pair — Interactive BDD REPL

Start an interactive pair programming session:

bin/phpspec pair

Or send a single prompt:

bin/phpspec pair --prompt "write a spec for a Calculator that adds two numbers"

The pair command launches a REPL where built-in commands (describe, exemplify, run) work without AI, and free-form input routes to the AI assistant when configured. Smart routing detects intent:

> describe App/Calculator                       # runs describe command
> describe what the Loader class does            # routes to AI (natural language)
> run spec/App                                   # runs specs
> run my specs and explain the failures          # routes to AI

The AI is agentic — it has tools to generate specs, features, step definitions, write/update files, run specs, and read project files. It receives your project's directory structure, existing step definitions, the full DSL reference, and matcher/mock syntax as context, so it writes code that matches your project's patterns and reuses existing steps.

A typical pairing flow:

> write a feature scenario for user registration

  ✓ Generated features/scenarios/user_registration.feature
  ✓ Generated features/steps/user_registration.steps.php

> run features/

  ✗ Class App\UserRepository not found

> describe App/UserRepository

  ✓ Generated spec/App/UserRepository.spec.php
  ✓ Generated src/App/UserRepository.php

> now add a findByEmail method that returns a User or null

  ✓ Updated spec/App/UserRepository.spec.php
  ✓ Updated src/App/UserRepository.php

> run

  ✓ All specs pass
  ✓ All scenarios pass

Conversation history is maintained across the session — the AI remembers what you've been working on and builds on previous exchanges. All interactions are logged to .phpspec/pair.log.

next — What should I work on?

bin/phpspec next

Scans your project's source, specs, and features, then suggests the single most impactful next step. It follows the scenario-first workflow — recommends a feature scenario before a spec, a spec before an implementation:

  Analysing project...

  Write a feature scenario for user registration.

  Your project has a UserRepository class with a spec, but no feature
  scenario covering the registration flow. Adding a scenario first will
  drive the step definitions and any missing specs.

refactor — AI-powered, behaviour-preserving refactoring

bin/phpspec refactor "App\Calculator"
bin/phpspec refactor "App\Calculator::sum"

The command:

  1. Runs specs to establish a green baseline (refuses to refactor broken code)
  2. Sends source + spec to the AI, which identifies a single baby-step refactoring
  3. Applies the change and re-runs specs
  4. If specs pass → keeps the change and shows a diff. If specs fail → rolls back automatically
Technique: Extract Method
Description: Extracted validation logic into a validateInput() method

   1   <?php
   2   namespace App;
   3 - class Calculator {
   3 + class Calculator {
   4       public function add(int $a, int $b): int {
   5 -         if ($a < 0 || $b < 0) {
   6 -             throw new \InvalidArgumentException('Negative');
   7 -         }
   5 +         $this->validateInput($a, $b);
              return $a + $b;
          }
  10 +
  11 +     private function validateInput(int $a, int $b): void {
  12 +         if ($a < 0 || $b < 0) {
  13 +             throw new \InvalidArgumentException('Negative');
  14 +         }
  15 +     }
      }

Specs still pass.

AI configuration

Add an ai: section to phpspec.yaml:

ai:
  provider: anthropic          # or google, openai
  model: claude-sonnet-4-20250514  # optional, sensible defaults per provider
  api_key: YOUR_API_KEY

Supported providers: Google (Gemini), Anthropic (Claude), OpenAI (GPT). The provider abstraction is behind an interface (ProviderInterface) — adding new providers is straightforward.

What's included

  • Full spec suite: 86 specs, 1030+ examples, 91%+ coverage
  • Feature scenarios for all major commands
  • Code generation (specs, classes, interfaces, methods)
  • Formatters: Pretty, Dot, TAP, JUnit XML
  • Parallel execution via PHP Fibers
  • AI pair programming, next-step suggestion, and automated refactoring (opt-in)
  • Documentation in docs/

Status

This is a proof of concept for discussion. It demonstrates the direction, not a final implementation. Feedback on the approach, syntax choices, and migration path is welcome.

Test plan

Core

  • composer install
  • php bin/phpspec run — all specs pass
  • php bin/phpspec run features/ — all feature scenarios pass
  • vendor/bin/php-cs-fixer fix --dry-run — code style clean
  • vendor/bin/phpstan analyse -l 1 src/ — static analysis clean

AI pair programming (optional — requires API key)

To try the AI flow, install a provider and add config:

composer require papi-ai/papi-core papi-ai/google   # or papi-ai/anthropic, papi-ai/openai
# phpspec.yaml
ai:
  provider: google       # or anthropic, openai
  api_key: YOUR_API_KEY

Then experiment with the BDD cycle:

  • php bin/phpspec pair — enter the interactive REPL; try built-in commands (describe App/Greeter, run) without AI first
  • In pair mode, ask the AI to generate a feature: write a feature scenario for greeting users — verify it creates .feature and .steps.php files
  • In pair mode, run the feature and let the AI drive the spec/class generation cycle: run features/describe App/Greeter → ask the AI to add methods → run until green
  • php bin/phpspec pair --prompt "write a spec for a Calculator" — single-prompt mode, verify it generates a spec file
  • php bin/phpspec next — verify it scans the project and suggests a next step following the scenario-first workflow
  • php bin/phpspec refactor "App\Greeter" — verify it runs specs, applies a refactoring, and re-runs specs (or reports no refactoring needed)

@MarcelloDuarte MarcelloDuarte force-pushed the phpspec-9-poc branch 5 times, most recently from 47a29bb to 3c692b8 Compare March 15, 2026 14:29
@MarcelloDuarte
Copy link
Member Author

@torchello @Jean85 @stof @everzet @ciaranmcnulty I also put together a website with the new concept

https://phpspec-site.fly.dev/

@Jean85
Copy link
Contributor

Jean85 commented Mar 16, 2026

Questions:

  1. would this mean rewriting it with AI? What about license concerns?
  2. Such a hard breaking change would stop a lot of users from upgrading; can we ease the transition in some way?

@stof
Copy link
Member

stof commented Mar 16, 2026

This looks like a totally different test framework. Maybe it should be released as a different package instead of a new major version of phpspec/phpspec to allow projects to use both at the same time to adopt the new tool little by little.

operating-system: ubuntu-latest
composer-flags: --prefer-lowest

- name: Run static analysis (psalm)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why removing static analysis from the CI setup ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had stan locally, didn't realise we had psalm until merging. Restored 7e23af2

asset_path: phpspec.phar
asset_name: phpspec.phar
asset_content_type: application/zip
- uses: actions/download-artifact@v4
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should not downgrade the action

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Restored to v5 9a9d91b

composer.json Outdated
"symfony/console": "^7.0 || ^8.0",
"symfony/yaml": "^7.0 || ^8.0",
"ext-dom": "*",
"ext-curl": "*",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why making ext-curl a mandatory requirement ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving it to suggest. It is used with some optional AI features 7fecb99

phpstan.neon Outdated
@@ -0,0 +1,6 @@
parameters:
level: 1
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

level 1 of phpstan is basically useless. A new codebase should actually start at level 8 or higher.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

restored psalm anyway

* Supports Background, Scenario, Scenario Outline with Examples tables, DataTables,
* Doc strings, tags, and And/But step keywords.
*/
final class GherkinParser
Copy link
Member

@stof stof Mar 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please don't write another Gherkin parser. Use cucumber/gherkin instead to use the official parser that is tested against the Gherkin shared testsuite (or use behat/gherkin if you prefer, but that would be better to use the official parser to be future-proof).

The current parser implementation is not spec compliant.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. 03d5dd0

* Manages a scope stack to track the current context/example registry during spec
* file loading, and dispatches events to registered subscribers and listeners.
*/
final class Dispatcher
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it expected that the dispatcher is all based on global state while not being internal ?

],
handler: function (array $args) use ($filesystem) {
$path = $args['path'];
if (!str_starts_with($path, '/')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this won't work on Windows. Absolute paths don't always start with /

return "File not found: {$args['path']}";
}

return $filesystem->read($path);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this protect against directory traversal like ../../../etc/password or /etc/password which are outside the project ?

* Static registry holding the Browser Client instance.
* Lazily creates the client from Configuration on first access.
*/
class BrowserRegistry
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

shouldn't this class be registered in its own file following PSR-4 ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thus, it is currently defined outside the phpspec namespace, which is even worse.

* Generates PHP class files from a fully qualified class name.
* Creates the directory structure and writes a minimal class skeleton.
*/
final class ClassGenerator
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of these classes should probably be tagged as @internal, to properly define the public API covered by semver.

*/
private function yamlEscape(string $value): string
{
if (str_contains($value, "\n") || str_contains($value, '"') || str_contains($value, ':')) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A string starting with a single quote will also need escaping, to avoid being parsed as a single-quoted string.

"php": "^8.2 || ^8.3 || ^8.4 || ^8.5",
"symfony/console": "^7.0 || ^8.0",
"symfony/yaml": "^7.0 || ^8.0",
"ext-dom": "*",
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isn't ext-dom needed only for the JUnit formatter ? If yes, it should be an optional dependency IMO.


## [9.0.0-poc] — Proof of Concept

### Breaking Changes
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

another breaking change: breaks compat with all existing phpspec extensions, as it is basically a different project.

@@ -0,0 +1,35 @@
# Changelog — PhpSpec 9
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why using a separate changelog file ?

<?php

/**
* Sanity-checks a release for consistency
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why ius this script gone ?

@stof
Copy link
Member

stof commented Mar 16, 2026

@MarcelloDuarte the website using gray text on a black background is almost unreadable. Please change the website styles to use good contrasting colors.

@MarcelloDuarte
Copy link
Member Author

Questions:

  1. would this mean rewriting it with AI? What about license concerns?
  2. Such a hard breaking change would stop a lot of users from upgrading; can we ease the transition in some way?

@Jean85 thanks for the feedback! 🙏

On the AI question — the code is original, written with AI as a development tool (same as using an IDE with autocomplete). No third-party code was incorporated, so no license concerns. I kept Konstantin in the copyright and added Ciaran for his longstanding contributions as a maitainer.

On the migration — we'd provide a migration tool to mechanically convert ObjectBehavior specs to the new syntax, and maintain 8.x with security/compatibility fixes during a transition period. Were you thinking of anything beyond that?

@MarcelloDuarte
Copy link
Member Author

@stof thanks for your feedback. I will go over one by one.

This looks like a totally different test framework. Maybe it should be released as a different package instead of a new major version of phpspec/phpspec to allow projects to use both at the same time to adopt the new tool little by little.

That is an excellent idea. Releasing this as phpspec/phpspec-9 or phpspec/phpspec-next makes the migration story much better. It would help with the issues raised by @Jean85 as well.

The downside is it fragments the brand/community a little, but for adoption it's arguably the pragmatic choice. If the adoption takes off, we could move to phpspec/phpspec on 9.1 to counter that.

@stof
Copy link
Member

stof commented Mar 16, 2026

Maybe using phpspec/runner could avoid the need to rename the package in the future by having an acceptable name.

Restores Psalm (level 4 with baseline) as the static analysis tool,
matching the upstream project's tooling choice.
checkout@v4 → v5, download-artifact@v4 → v5
Move version-dependent UndefinedInterfaceMethod suppression from
baseline to psalm.xml config to avoid stale baseline entries across
different Symfony Console versions.
Only needed by the optional AI provider packages, not by core.
Rewrite the hand-rolled Gherkin parser as a thin adapter over
cucumber/gherkin v39, the spec-compliant parser tested against the
Cucumber shared test suite. This adds i18n support, Rules, and
correct handling of all Gherkin edge cases.

The internal node types (FeatureNode, ScenarioNode, StepNode, etc.)
are unchanged — only the parsing layer is swapped.
Remove unused FeatureChild import and fix fn() spacing.
Cover the parse-error and null-document branches to satisfy the
90% coverage threshold.
Introduce DispatcherRegistry as a thin service locator so DSL global
functions can still reach the Dispatcher, while all class-level callers
receive it via constructor injection. InProcessRunner and
CommandDispatcher now swap the registry instance instead of
saving/restoring static state.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants