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