Skip to content

Type Inference

Pest BDD uses PHP type hints to automatically determine how to extract parameters from step text. This eliminates the need for explicit type annotations in your patterns.

The Core Concept

Traditional BDD tools require you to specify types in patterns:

php
// Behat style - types in pattern
/** @Given I have :count items */
public function haveItems($count) { }  // $count is string!

Pest BDD infers types from PHP:

php
// Pest BDD - types from PHP
#[Given('I have {count} items')]
public function haveItems(int $count): void { }  // $count is int!

Type to Regex Mapping

When Pest BDD encounters a placeholder {name}, it:

  1. Finds the corresponding method parameter
  2. Gets the parameter's type hint
  3. Generates an appropriate regex pattern
PHP TypeGenerated RegexMatches
string"[^"]*""quoted text"
int-?\d+42, -5, 0
float-?\d*\.?\d+3.14, .5, -2.5
booltrue|false|yes|no|1|0true, yes, 1
Other(treated as injection)N/A

String Parameters

Strings are matched within double quotes:

php
#[Given('a user {name} exists')]
public function userExists(string $name): void
{
    // Pattern: a user (?P<name>"[^"]*") exists
}

Gherkin:

gherkin
Given a user "John Doe" exists
# $name = "John Doe"

Given a user "Alice O'Connor" exists
# $name = "Alice O'Connor"

Given a user "" exists
# $name = "" (empty string)

Why Quotes?

Quoted strings:

  • Clearly delimit the value in natural language
  • Support spaces and special characters
  • Match Gherkin conventions

Integer Parameters

Integers match digits with optional negative sign:

php
#[Given('there are {count} items')]
public function itemsExist(int $count): void
{
    // Pattern: there are (?P<count>-?\d+) items
}

Gherkin:

gherkin
Given there are 5 items
# $count = 5

Given there are 100 items
# $count = 100

Given there are -3 items
# $count = -3

Given there are 0 items
# $count = 0

Float Parameters

Floats match decimal numbers:

php
#[Given('the price is {amount}')]
public function priceIs(float $amount): void
{
    // Pattern: the price is (?P<amount>-?\d*\.?\d+)
}

Gherkin:

gherkin
Given the price is 99.99
# $amount = 99.99

Given the price is .5
# $amount = 0.5

Given the price is 100
# $amount = 100.0

Given the price is -25.50
# $amount = -25.5

Boolean Parameters

Booleans match several common formats:

php
#[Given('notifications are {enabled}')]
public function notificationsEnabled(bool $enabled): void
{
    // Pattern: notifications are (?P<enabled>true|false|yes|no|1|0)
}

Gherkin:

gherkin
Given notifications are true
# $enabled = true

Given notifications are yes
# $enabled = true

Given notifications are 1
# $enabled = true

Given notifications are false
# $enabled = false

Given notifications are no
# $enabled = false

Given notifications are 0
# $enabled = false

Non-Builtin Types

When a parameter type isn't a builtin (string, int, float, bool), it's treated as an injection candidate rather than a pattern parameter:

php
#[When('the user places an order')]
public function placeOrder(User $user): Order
{
    // $user is NOT extracted from text
    // $user is INJECTED from context
    return Order::create(['user_id' => $user->id]);
}

See Auto-Injection for details.

Mixed Parameters

Combine extracted and injected parameters:

php
#[When('the user buys {quantity} of {product}')]
public function buy(User $user, int $quantity, string $product): void
{
    // $user - injected from context
    // $quantity - extracted as int
    // $product - extracted as string
}

Gherkin:

gherkin
When the user buys 3 of "Widget"
# Extracts: quantity=3, product="Widget"
# Injects: user from context

Parameter Order Flexibility

Injected parameters can be in any position:

php
// All equivalent - $user is injected regardless of position
public function buy(User $user, int $quantity, string $product): void { }
public function buy(int $quantity, User $user, string $product): void { }
public function buy(int $quantity, string $product, User $user): void { }

Type Casting

Extracted values are cast to their target types:

String Processing

Quotes are stripped from strings:

php
// Input: "Hello World"
// Result: Hello World (no quotes)

Integer Casting

php
// Input: "42"
// Result: 42 (int)

// Input: "-5"
// Result: -5 (int)

Float Casting

php
// Input: "3.14"
// Result: 3.14 (float)

// Input: ".5"
// Result: 0.5 (float)

Boolean Casting

php
// Input: "true", "yes", "1"
// Result: true (bool)

// Input: "false", "no", "0"
// Result: false (bool)

Compiled Patterns

For performance, patterns are compiled once and reused:

php
// CompiledStepDefinition.php
class CompiledStepDefinition
{
    public string $regex;
    public array $typeHints;

    public static function compile(StepDefinition $def): self
    {
        $compiled = new self();

        // Extract type hints from reflection
        $compiled->typeHints = PatternMatcher::extractTypeHints(
            $def->pattern,
            $def->method
        );

        // Build regex once
        $compiled->regex = PatternMatcher::buildRegex(
            $def->pattern,
            $compiled->typeHints
        );

        return $compiled;
    }

    public function match(string $text): ?array
    {
        if (preg_match($this->regex, $text, $matches)) {
            return $this->castValues($matches);
        }
        return null;
    }
}

Advanced Patterns

Multiple Parameters of Same Type

php
#[Given('I transfer {amount} from {source} to {target}')]
public function transfer(float $amount, string $source, string $target): void
{
    // amount: float
    // source: string
    // target: string
}
gherkin
Given I transfer 100.50 from "Checking" to "Savings"

Optional Parameters with Defaults

php
#[Given('a user exists')]
public function userExists(string $name = 'Default User'): User
{
    return User::factory()->create(['name' => $name]);
}

#[Given('a user {name} exists')]
public function namedUserExists(string $name): User
{
    return User::factory()->create(['name' => $name]);
}

The first pattern has no placeholders, so it uses the default. The second extracts the name.

Nullable Types

php
#[When('I search for {query}')]
public function search(?string $query, User $user): void
{
    // $query extracted from pattern (required)
    // $user injected from context (nullable if not found)
}

Debugging Type Inference

Check Pattern Compilation

If a pattern isn't matching, verify the generated regex:

php
// Temporary debug
$matcher = new PatternMatcher();
$typeHints = ['count' => 'int'];
$regex = $matcher->buildRegex('I have {count} items', $typeHints);
dump($regex);  // /^I have (?P<count>-?\d+) items$/u

Common Issues

Issue: String not matching

gherkin
Given a user John exists  # Missing quotes!

Fix:

gherkin
Given a user "John" exists  # Add quotes

Issue: Wrong type hint

php
#[Given('there are {count} items')]
public function items(string $count): void  // Should be int!

Fix:

php
#[Given('there are {count} items')]
public function items(int $count): void

Issue: Placeholder name mismatch

php
#[Given('I have {count} items')]
public function items(int $total): void  // count ≠ total

Fix:

php
#[Given('I have {count} items')]
public function items(int $count): void  // Names match

Static Method Type Inference

Type inference works identically for static methods:

php
class User extends Model
{
    #[Given('a user {name} with {age} years exists')]
    public static function createWithAge(string $name, int $age): self
    {
        return self::create(['name' => $name, 'age' => $age]);
    }
}
gherkin
Given a user "John" with 25 years exists
# Extracted: name="John", age=25
# Returns: User instance

Static methods can also combine extracted and injected parameters:

php
class Order extends Model
{
    #[When('user places order for {amount}')]
    public static function createForUser(User $user, float $amount): self
    {
        // $user - injected from context
        // $amount - extracted from pattern
        return self::create([
            'user_id' => $user->id,
            'total' => $amount,
        ]);
    }
}

See Static Methods for more details.

Released under the MIT License.