Auto-Injection
Auto-injection is one of Pest BDD's most powerful features. It automatically provides objects created in earlier steps to later steps, eliminating manual state management.
How It Works
When a step method returns an object, it's stored in the scenario context. Later steps can receive that object by declaring a parameter of the matching type.
// Step 1: Create and return a User
#[Given('a user {name} exists')]
public function userExists(string $name): User
{
return User::factory()->create(['name' => $name]);
}
// Step 2: User is automatically injected
#[When('the user logs in')]
public function login(User $user): void
{
// $user is the same instance from Step 1
$this->actingAs($user);
}
// Step 3: User is still available
#[Then('the user should see the dashboard')]
public function seeDashboard(User $user): void
{
// Same $user instance
expect($user->isLoggedIn())->toBeTrue();
}ScenarioContext Storage
The ScenarioContext class manages three types of storage:
1. Type Storage
Objects stored by their class name (and parent classes/interfaces):
// When you return a User
return User::factory()->create();
// It's stored as:
$context->typeStorage['App\Models\User'] = $user;
$context->typeStorage['Illuminate\Database\Eloquent\Model'] = $user;
$context->typeStorage['Illuminate\Contracts\Auth\Authenticatable'] = $user;This means you can request it by any of these types:
public function step(User $user): void { } // Works
public function step(Model $model): void { } // Works (gets User)
public function step(Authenticatable $auth): void { } // Works (gets User)2. Alias Storage
Objects stored by a custom name:
Given a user "John" exists as {admin}$context->aliasStorage['admin'] = $user;Access by parameter name:
public function step(User $admin): void
{
// $admin injected because parameter name matches alias
}3. Instance Storage
Reusable step class instances:
$context->instances['CalculatorSteps'] = $stepInstance;This allows state to persist within step classes during a scenario.
Resolution Order
When resolving a parameter, Pest BDD tries these sources in order:
1. Alias Injection (Highest Priority)
If parameter name matches an alias:
// Given a user exists as {buyer}
public function step(User $buyer): void
{
// $buyer found by alias "buyer"
}2. Type Injection
If parameter type is a class and exists in context:
// Previous step returned a User
public function step(User $user): void
{
// $user found by type User
}Also checks parent classes and interfaces:
public function step(Model $model): void
{
// Gets the User if no direct Model match
}3. Extracted Values
If parameter name matches a pattern placeholder:
#[Given('there are {count} items')]
public function step(int $count): void
{
// $count extracted from step text
}4. Model Query Fallback
If parameter type is an Eloquent Model and extracted value is numeric:
#[When('order {id} is processed')]
public function process(Order $order): void
{
// If no Order in context, queries Order::find($id)
}This fallback enables referencing models by ID:
Given order 42 exists in the database
When order 42 is processed
# $order = Order::find(42)The query only happens when:
- Parameter type extends
Illuminate\Database\Eloquent\Model - No instance exists in context
- Extracted value is numeric
After querying, the model is stored in context for subsequent steps.
5. Default Values
If parameter has a default:
public function step(int $count = 10): void
{
// Uses 10 if count not extracted or injected
}6. Nullable Types
If parameter is nullable:
public function step(?User $user): void
{
// $user is null if no User in context
}Practical Examples
Basic Injection
#[Given('a product {name} exists')]
public function productExists(string $name): Product
{
return Product::factory()->create(['name' => $name]);
}
#[When('I add the product to cart')]
public function addToCart(Product $product, Cart $cart): void
{
// $product injected from Given step
// $cart injected if exists, or use default
$cart->add($product);
}
#[Then('the cart should contain the product')]
public function cartContains(Product $product, Cart $cart): void
{
expect($cart->items)->toContain($product);
}Multiple Objects
#[Given('a user {name} exists')]
public function userExists(string $name): User
{
return User::factory()->create(['name' => $name]);
}
#[Given('a post {title} exists')]
public function postExists(string $title): Post
{
return Post::factory()->create(['title' => $title]);
}
#[When('the user comments on the post')]
public function userComments(User $user, Post $post): Comment
{
// Both injected from previous steps
return Comment::create([
'user_id' => $user->id,
'post_id' => $post->id,
'body' => 'Great post!',
]);
}
#[Then('the post should have a comment')]
public function postHasComment(Post $post, Comment $comment): void
{
expect($post->comments)->toContain($comment);
}Alias-Based Injection
Scenario: Transfer between accounts
Given a user "Alice" exists as {sender}
And a user "Bob" exists as {receiver}
When sender transfers to receiver
Then sender balance should decrease
And receiver balance should increase#[When('sender transfers to receiver')]
public function transfer(User $sender, User $receiver): void
{
// $sender matched by alias "sender"
// $receiver matched by alias "receiver"
$sender->transferTo($receiver, 100);
}
#[Then('sender balance should decrease')]
public function senderBalanceDecreased(User $sender): void
{
expect($sender->fresh()->balance)->toBeLessThan(1000);
}
#[Then('receiver balance should increase')]
public function receiverBalanceIncreased(User $receiver): void
{
expect($receiver->fresh()->balance)->toBeGreaterThan(0);
}Mixed Injection and Extraction
#[When('the user purchases {quantity} items')]
public function purchase(User $user, int $quantity): Order
{
// $user - injected from context
// $quantity - extracted from step text
return Order::create([
'user_id' => $user->id,
'quantity' => $quantity,
]);
}Origin Tracking
ScenarioContext tracks where each object came from, useful for debugging:
// When storing
$context->store($user, 'admin');
$context->typeOrigins[User::class] = 'Given a user "John" exists';
$context->aliasOrigins['admin'] = 'Given a user "John" exists as {admin}';When injection fails, error messages include origin information:
Failed to resolve parameter $user in step "Then the user should be active"
Available in context:
- User (from "Given a user exists")
- Order (from "When the user places an order")
Did you mean to use one of these?Inheritance Support
Objects are stored by their full class hierarchy:
class AdminUser extends User implements AdminInterface { }
#[Given('an admin exists')]
public function adminExists(): AdminUser
{
return AdminUser::factory()->create();
}
// All of these work:
public function step(AdminUser $admin): void { }
public function step(User $user): void { }
public function step(Model $model): void { }
public function step(AdminInterface $admin): void { }Interface-Based Injection
Request by interface for flexibility:
interface PaymentGateway { }
class StripeGateway implements PaymentGateway { }
#[Given('Stripe is configured')]
public function stripeConfigured(): StripeGateway
{
return new StripeGateway(config('stripe'));
}
#[When('I process payment')]
public function processPayment(PaymentGateway $gateway): void
{
// Gets StripeGateway via interface
$gateway->charge(100);
}Best Practices
Return Values for Injection
Always return objects you want available later:
// Good: Returns for injection
#[Given('a user exists')]
public function userExists(): User
{
return User::factory()->create();
}
// Less useful: No return
#[Given('a user exists')]
public function userExists(): void
{
User::factory()->create(); // Lost!
}Use Specific Types
Be specific about what you need:
// Good: Specific type
public function step(User $user): void { }
// Avoid: Too generic
public function step(Model $model): void { }Alias for Multiple Same-Type Objects
When you need multiple instances of the same type:
Given a user exists as {buyer}
Given a user exists as {seller}public function step(User $buyer, User $seller): void
{
// Clear which is which
}Document Injection Dependencies
/**
* Processes payment for the current order.
*
* @requires User from "Given a user exists"
* @requires Order from "When the user creates an order"
*/
#[When('the user pays')]
public function userPays(User $user, Order $order): Payment
{
return Payment::process($user, $order);
}