Skip to content

Service Container Integration ​

Pest BDD seamlessly integrates with Laravel's Service Container, enabling dependency injection in your step definitions. This means you can use services, repositories, and any other container-bound classes directly in your steps.

How It Works ​

When executing step methods, Pest BDD uses Laravel's app() helper to resolve class instances:

php
// Pest BDD internally uses:
$instance = app(OrderService::class);
$instance->processOrder($order);

If Laravel is not available, it falls back to simple instantiation with new.

Constructor Dependency Injection ​

Step classes can declare dependencies in their constructors:

php
use TestFlowLabs\PestTestAttributes\Then;
use TestFlowLabs\PestTestAttributes\When;

class OrderSteps
{
    public function __construct(
        private OrderService $orderService,
        private PaymentGateway $paymentGateway,
    ) {}

    #[When('I place an order')]
    public function placeOrder(User $user): Order
    {
        return $this->orderService->createOrder($user);
    }

    #[When('I pay for the order')]
    public function payForOrder(Order $order): void
    {
        $this->paymentGateway->charge($order);
    }

    #[Then('the order should be paid')]
    public function orderShouldBePaid(Order $order): void
    {
        expect($order->fresh()->isPaid())->toBeTrue();
    }
}

Laravel's container automatically resolves OrderService and PaymentGateway when creating the step class instance.

Service Classes as Steps ​

You can put step attributes directly on your service classes:

php
namespace App\Services;

use TestFlowLabs\PestTestAttributes\When;
use App\Repositories\UserRepository;

class UserService
{
    public function __construct(
        private UserRepository $repository,
        private NotificationService $notifications,
    ) {}

    #[When('user {email} is created')]
    public function createUser(string $email): User
    {
        $user = $this->repository->create(['email' => $email]);
        $this->notifications->sendWelcome($user);

        return $user;
    }

    #[When('user is deactivated')]
    public function deactivate(User $user): void
    {
        $this->repository->deactivate($user);
        $this->notifications->sendDeactivation($user);
    }
}

This approach has several benefits:

  • Step logic lives with related service code
  • Full DI support - same as any Laravel service
  • Services remain testable independently
  • Encourages reuse of existing application code

Action Classes ​

Laravel Action classes work perfectly as step definitions:

php
namespace App\Actions;

use TestFlowLabs\PestTestAttributes\When;

class CreateOrder
{
    public function __construct(
        private InventoryService $inventory,
        private PricingService $pricing,
    ) {}

    #[When('an order is created for {product}')]
    public function __invoke(string $product, User $user): Order
    {
        $price = $this->pricing->getPrice($product);
        $this->inventory->reserve($product);

        return Order::create([
            'user_id' => $user->id,
            'product' => $product,
            'price' => $price,
        ]);
    }
}

For invokable actions, use the #[When] attribute on the __invoke method.

Repository Pattern ​

Repositories with constructor dependencies work seamlessly:

php
namespace App\Repositories;

use TestFlowLabs\PestTestAttributes\Given;
use Illuminate\Database\DatabaseManager;

class UserRepository
{
    public function __construct(
        private DatabaseManager $db,
        private CacheManager $cache,
    ) {}

    #[Given('a user with email {email} exists')]
    public function createWithEmail(string $email): User
    {
        $user = User::create(['email' => $email, 'name' => fake()->name()]);
        $this->cache->forget("user:{$email}");

        return $user;
    }

    #[Given('no users exist')]
    public function clearAll(): void
    {
        $this->db->table('users')->truncate();
        $this->cache->flush();
    }
}

Interface Bindings ​

If you bind interfaces in your service provider, Pest BDD resolves them correctly:

php
// AppServiceProvider
$this->app->bind(PaymentGatewayInterface::class, StripeGateway::class);
php
class PaymentSteps
{
    public function __construct(
        private PaymentGatewayInterface $gateway,  // Gets StripeGateway
    ) {}

    #[When('payment is processed')]
    public function processPayment(Order $order): void
    {
        $this->gateway->charge($order->total);
    }
}

Singleton Services ​

Singleton bindings are respected:

php
// AppServiceProvider
$this->app->singleton(MetricsCollector::class);
php
class AnalyticsSteps
{
    public function __construct(
        private MetricsCollector $metrics,  // Same instance across steps
    ) {}

    #[When('I track event {name}')]
    public function trackEvent(string $name): void
    {
        $this->metrics->record($name);
    }
}

The same MetricsCollector instance is used throughout the scenario.

Contextual Binding ​

Laravel's contextual bindings work as expected:

php
// AppServiceProvider
$this->app->when(OrderService::class)
    ->needs(LoggerInterface::class)
    ->give(OrderLogger::class);
php
class OrderService
{
    public function __construct(
        private LoggerInterface $logger,  // Gets OrderLogger
    ) {}

    #[When('order is placed')]
    public function placeOrder(User $user): Order
    {
        $order = Order::create(['user_id' => $user->id]);
        $this->logger->info("Order placed: {$order->id}");

        return $order;
    }
}

Non-Laravel Fallback ​

When Laravel's container is not available (non-Laravel projects), Pest BDD falls back to simple instantiation:

php
// Without Laravel:
$instance = new OrderSteps();  // Requires parameterless constructor

For non-Laravel projects, step classes must have:

  • No constructor, OR
  • Constructor with only optional parameters
php
// Works without Laravel
class SimpleSteps
{
    #[Given('something exists')]
    public function exists(): void
    {
        // ...
    }
}

// Also works without Laravel
class ConfigurableSteps
{
    public function __construct(
        private string $prefix = 'default',
    ) {}

    #[Given('a {item} exists')]
    public function itemExists(string $item): void
    {
        // Uses $this->prefix
    }
}

Instance Caching ​

Within a single scenario, step class instances are cached:

php
class CounterSteps
{
    private int $count = 0;

    #[When('I increment')]
    public function increment(): void
    {
        $this->count++;
    }

    #[Then('count should be {expected}')]
    public function assertCount(int $expected): void
    {
        expect($this->count)->toBe($expected);
    }
}
gherkin
Scenario: Counter increments
  When I increment
  And I increment
  And I increment
  Then count should be 3

The same CounterSteps instance is used for all steps, so state is preserved.

Testing with Container ​

You can mock services in your tests:

php
beforeEach(function () {
    $this->mock(PaymentGateway::class, function ($mock) {
        $mock->shouldReceive('charge')->andReturn(true);
    });
});

These mocks are automatically used by step classes that depend on them.

Best Practices ​

Keep Step Classes Focused ​

php
// Good: Focused on user operations
class UserSteps
{
    public function __construct(private UserService $users) {}

    #[Given('a user exists')]
    public function userExists(): User { }

    #[When('user is updated')]
    public function update(User $user): void { }
}

// Avoid: Too many responsibilities
class EverythingSteps
{
    public function __construct(
        private UserService $users,
        private OrderService $orders,
        private PaymentService $payments,
        // Too many dependencies...
    ) {}
}

Use Constructor Injection ​

php
// Good: Dependencies declared in constructor
class OrderSteps
{
    public function __construct(
        private OrderService $service,
    ) {}
}

// Avoid: Service location in methods
class OrderSteps
{
    #[When('order is created')]
    public function create(): void
    {
        $service = app(OrderService::class);  // Avoid this
    }
}

Leverage Existing Services ​

php
// Good: Reuse existing application services
#[When('notification is sent')]
public function send(User $user): void
{
    $this->notificationService->send($user, 'welcome');
}

// Avoid: Duplicating logic
#[When('notification is sent')]
public function send(User $user): void
{
    // Don't duplicate what NotificationService already does
    Mail::to($user)->send(new WelcomeEmail($user));
    Notification::send($user, new WelcomeNotification());
}

Released under the MIT License.