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:
// Behat style - types in pattern
/** @Given I have :count items */
public function haveItems($count) { } // $count is string!Pest BDD infers types from 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:
- Finds the corresponding method parameter
- Gets the parameter's type hint
- Generates an appropriate regex pattern
| PHP Type | Generated Regex | Matches |
|---|---|---|
string | "[^"]*" | "quoted text" |
int | -?\d+ | 42, -5, 0 |
float | -?\d*\.?\d+ | 3.14, .5, -2.5 |
bool | true|false|yes|no|1|0 | true, yes, 1 |
| Other | (treated as injection) | N/A |
String Parameters
Strings are matched within double quotes:
#[Given('a user {name} exists')]
public function userExists(string $name): void
{
// Pattern: a user (?P<name>"[^"]*") exists
}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:
#[Given('there are {count} items')]
public function itemsExist(int $count): void
{
// Pattern: there are (?P<count>-?\d+) items
}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 = 0Float Parameters
Floats match decimal numbers:
#[Given('the price is {amount}')]
public function priceIs(float $amount): void
{
// Pattern: the price is (?P<amount>-?\d*\.?\d+)
}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.5Boolean Parameters
Booleans match several common formats:
#[Given('notifications are {enabled}')]
public function notificationsEnabled(bool $enabled): void
{
// Pattern: notifications are (?P<enabled>true|false|yes|no|1|0)
}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 = falseNon-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:
#[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:
#[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:
When the user buys 3 of "Widget"
# Extracts: quantity=3, product="Widget"
# Injects: user from contextParameter Order Flexibility
Injected parameters can be in any position:
// 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:
// Input: "Hello World"
// Result: Hello World (no quotes)Integer Casting
// Input: "42"
// Result: 42 (int)
// Input: "-5"
// Result: -5 (int)Float Casting
// Input: "3.14"
// Result: 3.14 (float)
// Input: ".5"
// Result: 0.5 (float)Boolean Casting
// Input: "true", "yes", "1"
// Result: true (bool)
// Input: "false", "no", "0"
// Result: false (bool)Compiled Patterns
For performance, patterns are compiled once and reused:
// 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
#[Given('I transfer {amount} from {source} to {target}')]
public function transfer(float $amount, string $source, string $target): void
{
// amount: float
// source: string
// target: string
}Given I transfer 100.50 from "Checking" to "Savings"Optional Parameters with Defaults
#[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
#[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:
// Temporary debug
$matcher = new PatternMatcher();
$typeHints = ['count' => 'int'];
$regex = $matcher->buildRegex('I have {count} items', $typeHints);
dump($regex); // /^I have (?P<count>-?\d+) items$/uCommon Issues
Issue: String not matching
Given a user John exists # Missing quotes!Fix:
Given a user "John" exists # Add quotesIssue: Wrong type hint
#[Given('there are {count} items')]
public function items(string $count): void // Should be int!Fix:
#[Given('there are {count} items')]
public function items(int $count): voidIssue: Placeholder name mismatch
#[Given('I have {count} items')]
public function items(int $total): void // count ≠ totalFix:
#[Given('I have {count} items')]
public function items(int $count): void // Names matchStatic Method Type Inference
Type inference works identically for static methods:
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]);
}
}Given a user "John" with 25 years exists
# Extracted: name="John", age=25
# Returns: User instanceStatic methods can also combine extracted and injected parameters:
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.