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.