Skip to main content

Overview

Eufaturo Billing uses the Action pattern for all business logic. Actions are single-purpose classes that execute one operation, like creating a product or canceling a subscription. They work with DTOs (Data Transfer Objects) to receive parameters in a type-safe, structured way.
Actions replace fat models and bloated controllers, keeping your code clean, testable, and maintainable.

Why Actions?

The Problem with Traditional Approaches

Fat Models (Active Record Hell):
// ❌ Business logic in models
class Product extends Model
{
    public function createWithPlans($name, $plans)
    {
        DB::transaction(function () use ($name, $plans) {
            $this->name = $name;
            $this->save();

            foreach ($plans as $plan) {
                $this->plans()->create($plan);
            }

            event(new ProductCreated($this));
        });
    }
}
Problems:
  • Business logic mixed with data layer
  • Hard to test in isolation
  • No dependency injection
  • Models become thousands of lines
  • Violates Single Responsibility Principle
Fat Controllers:
// ❌ Business logic in controllers
class ProductController extends Controller
{
    public function store(Request $request)
    {
        DB::beginTransaction();
        try {
            $product = new Product();
            $product->name = $request->name;
            $product->slug = Str::slug($request->name);
            $product->save();

            foreach ($request->plans as $plan) {
                // Complex logic here...
            }

            DB::commit();
            event(new ProductCreated($product));
        } catch (\Exception $e) {
            DB::rollBack();
            throw $e;
        }
    }
}
Problems:
  • Controllers become bloated
  • Hard to reuse logic
  • Testing requires HTTP requests
  • No clear separation of concerns

The Action Solution

// ✅ Clean, focused action
final readonly class CreateProduct
{
    public function __construct(
        private ConnectionInterface $connection,
    ) {}

    public function execute(CreateProductDto $dto): Product
    {
        try {
            $this->connection->beginTransaction();

            $product = new Product();
            $product->name = $dto->name;
            $product->slug = $dto->slug ?? Str::slug($dto->name);
            $product->is_visible = $dto->isVisible;
            $product->save();

            $this->connection->commit();

            return $product;
        } catch (Throwable $th) {
            $this->connection->rollBack();
            throw $th;
        }
    }
}
Benefits:
  • ✅ Single responsibility
  • ✅ Easy to test
  • ✅ Reusable anywhere (controllers, commands, jobs, listeners)
  • ✅ Clear dependencies
  • ✅ Transaction management
  • ✅ Type-safe

Action Anatomy

Basic Structure

Every action follows this pattern:
final readonly class ActionName
{
    // 1. Constructor Injection
    public function __construct(
        private ConnectionInterface $connection,
        // Other dependencies...
    ) {}

    // 2. Execute Method
    public function execute(DtoName $dto): ReturnType
    {
        try {
            // 3. Begin Transaction
            $this->connection->beginTransaction();

            // 4. Business Logic
            // ...

            // 5. Commit Transaction
            $this->connection->commit();

            // 6. Return Result
            return $result;
        } catch (Throwable $th) {
            // 7. Rollback on Error
            $this->connection->rollBack();
            throw $th;
        }
    }
}

Real Example: CreateSubscription

final readonly class CreateSubscription
{
    public function __construct(
        private ConnectionInterface $connection,
        private EventDispatcher $events,
    ) {}

    public function execute(CreateSubscriptionDto $dto): Subscription
    {
        try {
            $this->connection->beginTransaction();

            // Create subscription
            $subscription = new Subscription();
            $subscription->billable()->associate($dto->billable);
            $subscription->plan()->associate($dto->plan);
            $subscription->currency()->associate($dto->plan->currency);
            $subscription->type = $dto->type;
            $subscription->status = SubscriptionStatus::New;
            $subscription->price = $dto->plan->price;
            $subscription->interval = $dto->plan->interval;
            $subscription->interval_count = $dto->plan->interval_count;

            // Handle trial period
            if ($dto->trialEnds) {
                $subscription->trial_starts_at = now();
                $subscription->trial_ends_at = $dto->trialEnds;
            } elseif ($dto->plan->hasTrial()) {
                $subscription->trial_starts_at = now();
                $subscription->trial_ends_at = now()->add(
                    $dto->plan->trial_interval->dateIdentifier(),
                    $dto->plan->trial_interval_count
                );
            }

            $subscription->save();

            $this->connection->commit();

            // Emit event after successful commit
            $this->events->dispatch(
                new SubscriptionCreated($subscription)
            );

            return $subscription;
        } catch (Throwable $th) {
            $this->connection->rollBack();
            throw $th;
        }
    }
}

Key Action Concepts

1. final readonly

Actions are immutable:
final readonly class CreateProduct
{
    // All properties are readonly by default
    public function __construct(
        private ConnectionInterface $connection,
    ) {}
}
Why?
  • final - Can’t be extended (actions should be simple, not inherited)
  • readonly - Properties can’t be modified after construction
  • Thread-safe - No mutable state
  • Predictable - No side effects from state changes

2. Constructor Injection

Dependencies are injected via constructor:
public function __construct(
    private ConnectionInterface $connection,
    private EventDispatcher $events,
    private ProrationCalculator $calculator,
) {}
Resolved Automatically:
// Laravel container handles injection
$action = app(CreateSubscription::class);
For Testing:
// Easy to mock dependencies
$mockConnection = $this->mock(ConnectionInterface::class);
$action = new CreateSubscription($mockConnection, $mockEvents);

3. Single Execute Method

One action = one operation:
public function execute(CreateProductDto $dto): Product
{
    // One clear purpose
}
Not:
// ❌ Don't do this
public function create() {}
public function update() {}
public function delete() {}

4. Transaction Management

Always wrap in transactions:
try {
    $this->connection->beginTransaction();
    // ... business logic
    $this->connection->commit();
    return $result;
} catch (Throwable $th) {
    $this->connection->rollBack();
    throw $th;
}
Why?
  • Ensures data consistency
  • Automatic rollback on error
  • Multiple operations succeed or fail together

5. Event Dispatch After Commit

Emit events only after successful commit:
$this->connection->commit();

// ✅ Emit AFTER commit
$this->events->dispatch(new SubscriptionCreated($subscription));
Not:
// ❌ Don't emit before commit
$this->events->dispatch(new SubscriptionCreated($subscription));
$this->connection->commit(); // What if this fails?

Data Transfer Objects (DTOs)

What Are DTOs?

Immutable data containers for passing parameters:
final readonly class CreateProductDto
{
    public function __construct(
        public string $name,
        public ?string $slug = null,
        public ?string $description = null,
        public bool $isVisible = true,
        public bool $isPopular = false,
        public bool $isDefault = false,
        public array $metadata = [],
        public array $features = [],
    ) {}
}

Why DTOs?

Without DTOs:
// ❌ Parameter hell
public function execute(
    string $name,
    ?string $slug = null,
    ?string $description = null,
    bool $isVisible = true,
    bool $isPopular = false,
    bool $isDefault = false,
    array $metadata = [],
    array $features = [],
): Product
Problems:
  • Hard to read
  • Easy to mix up parameters
  • Adding new params = breaking change
  • No IDE autocomplete for params
With DTOs:
// ✅ Clean
public function execute(CreateProductDto $dto): Product
Benefits:
  • ✅ Single parameter
  • ✅ Named parameters at call site
  • ✅ Type-safe
  • ✅ IDE autocomplete
  • ✅ Easy to extend (add new fields without breaking)
  • ✅ Self-documenting

Using DTOs

Creating a DTO:
use Eufaturo\Billing\Products\Dto\CreateProductDto;

$dto = new CreateProductDto(
    name: 'Pro Plan',
    slug: 'pro',
    description: 'For professionals',
    isVisible: true,
    isPopular: true,
    metadata: [
        'badge' => 'Most Popular',
    ],
);
Named Parameters:
// ✅ Clear what each value means
new CreateProductDto(
    name: 'Pro Plan',
    isVisible: true,
    isPopular: true,
);

// ❌ Without names (confusing)
new CreateProductDto('Pro Plan', null, null, true, true);
Optional Parameters:
// Only provide what you need
$dto = new CreateProductDto(
    name: 'Pro Plan',
    // All others use defaults
);

DTO Best Practices

1. Always readonly

// ✅ Correct
final readonly class CreateProductDto {}

// ❌ Wrong
final class CreateProductDto {} // Mutable!

2. Use Type Hints

// ✅ Type-safe
public function __construct(
    public string $name,
    public int $price,
    public Interval $interval,
) {}

// ❌ No type safety
public function __construct(
    public $name,
    public $price,
    public $interval,
) {}

3. Optional Params Last

// ✅ Required first, optional last
public function __construct(
    public string $name,        // Required
    public int $price,          // Required
    public ?string $slug = null, // Optional
    public array $metadata = [],  // Optional
) {}

4. Sensible Defaults

public function __construct(
    public string $name,
    public bool $isVisible = true,  // ✅ Good default
    public bool $isPopular = false, // ✅ Good default
    public array $metadata = [],     // ✅ Empty array
) {}

Using Actions

In Managers

Managers coordinate actions:
readonly class ProductManager
{
    public function __construct(
        private CreateProduct $createProduct,
    ) {}

    public function create(/* params */): Product
    {
        return $this->createProduct->execute(
            new CreateProductDto(/* ... */)
        );
    }
}

In Controllers

use Eufaturo\Billing\Products\Actions\CreateProduct;
use Eufaturo\Billing\Products\Dto\CreateProductDto;

class ProductController extends Controller
{
    public function store(
        Request $request,
        CreateProduct $createProduct
    ) {
        $product = $createProduct->execute(
            new CreateProductDto(
                name: $request->input('name'),
                description: $request->input('description'),
                isVisible: $request->boolean('is_visible'),
            )
        );

        return redirect()->route('products.show', $product);
    }
}

In Artisan Commands

use Eufaturo\Billing\Products\Actions\CreateProduct;
use Eufaturo\Billing\Products\Dto\CreateProductDto;

class CreateProductCommand extends Command
{
    public function handle(CreateProduct $createProduct): void
    {
        $product = $createProduct->execute(
            new CreateProductDto(
                name: $this->argument('name'),
            )
        );

        $this->info("Created: {$product->name}");
    }
}

In Jobs

use Eufaturo\Billing\Subscriptions\Actions\CancelSubscription;
use Eufaturo\Billing\Subscriptions\Dto\CancelSubscriptionDto;

class CancelExpiredTrials implements ShouldQueue
{
    public function handle(CancelSubscription $cancelSubscription): void
    {
        $subscriptions = Subscription::whereDate('trial_ends_at', '<', now())->get();

        foreach ($subscriptions as $subscription) {
            $cancelSubscription->execute(
                new CancelSubscriptionDto(
                    subscription: $subscription,
                    immediately: true,
                    reason: 'Trial expired',
                )
            );
        }
    }
}

In Event Listeners

class SendWelcomeEmail
{
    public function handle(SubscriptionCreated $event): void
    {
        Mail::to($event->subscription->billable->getBillableEmail())
            ->send(new WelcomeEmail($event->subscription));
    }
}

Testing Actions

Unit Testing

Test actions in isolation:
use Eufaturo\Billing\Products\Actions\CreateProduct;
use Eufaturo\Billing\Products\Dto\CreateProductDto;

test('can create a product', function () {
    $action = app(CreateProduct::class);

    $product = $action->execute(
        new CreateProductDto(
            name: 'Test Product',
            slug: 'test-product',
        )
    );

    expect($product->name)->toBe('Test Product');
    expect($product->slug)->toBe('test-product');
    expect($product->exists)->toBeTrue();
});

Testing Rollback

test('rolls back transaction on error', function () {
    $mockConnection = $this->createConnectionInterfaceMock();

    $action = app(CreateProduct::class, [
        'connection' => $mockConnection,
    ]);

    expect(fn() => $action->execute(
        new CreateProductDto(name: 'Test')
    ))->toThrow(RuntimeException::class);

    // Verify no product was created
    expect(Product::where('name', 'Test')->exists())->toBeFalse();
});

Testing with Dependencies

test('emits event after creation', function () {
    Event::fake();

    $action = app(CreateProduct::class);

    $product = $action->execute(
        new CreateProductDto(name: 'Test')
    );

    Event::assertDispatched(ProductCreated::class, function ($event) use ($product) {
        return $event->product->id === $product->id;
    });
});

Common Patterns

Validation in Actions

final readonly class CreateSubscription
{
    public function execute(CreateSubscriptionDto $dto): Subscription
    {
        // Validate business rules
        if ($dto->billable->isSubscribed($dto->plan)) {
            throw new AlreadySubscribedException();
        }

        // Proceed with creation...
    }
}

Calling Other Actions

Sometimes one action needs another:
final readonly class CreateProductWithPlans
{
    public function __construct(
        private ConnectionInterface $connection,
        private CreateProduct $createProduct,
        private CreateProductPlan $createPlan,
    ) {}

    public function execute(CreateProductWithPlansDto $dto): Product
    {
        try {
            $this->connection->beginTransaction();

            // Create product
            $product = $this->createProduct->execute(
                new CreateProductDto(name: $dto->name)
            );

            // Create plans
            foreach ($dto->plans as $planData) {
                $this->createPlan->execute(
                    new CreateProductPlanDto(
                        product: $product,
                        // ...
                    )
                );
            }

            $this->connection->commit();
            return $product;
        } catch (Throwable $th) {
            $this->connection->rollBack();
            throw $th;
        }
    }
}
When calling actions from other actions, wrap everything in one transaction. Don’t start nested transactions!

Conditional Logic

public function execute(CreateSubscriptionDto $dto): Subscription
{
    try {
        $this->connection->beginTransaction();

        $subscription = new Subscription();
        // ... basic setup

        // Conditional: Gateway-managed vs Locally-managed
        if ($dto->type === SubscriptionType::PaymentGatewayManaged) {
            // Create gateway subscription
            $gatewaySubscription = $this->gateway->createSubscription($dto);

            // Store gateway reference
            $subscription->gatewayRecords()->create([
                'external_id' => $gatewaySubscription->id,
            ]);
        }

        $subscription->save();
        $this->connection->commit();
        return $subscription;
    } catch (Throwable $th) {
        $this->connection->rollBack();
        throw $th;
    }
}

When NOT to Use Actions

Simple CRUD

For basic CRUD without business logic, models are fine:
// ✅ Simple update - model is fine
$product = Product::find(1);
$product->name = 'Updated Name';
$product->save();

// Actions are overkill for trivial operations

Single Database Operation

// ✅ Simple query - no action needed
$products = Product::where('is_visible', true)->get();
Use Actions When:
  • Multiple operations needed
  • Business rules to enforce
  • Transactions required
  • Events to emit
  • External services involved
  • Complex calculations
  • Multiple models affected

Next Steps


Further Reading