Skip to main content

Overview

Eufaturo Billing is built with modern PHP patterns and best practices. Understanding the architecture will help you effectively use the package and extend it for your needs.

Core Principles

1. Gateway-Agnostic Design

The billing logic is completely separated from payment gateway implementations:
Your App

Billing Core (Products, Plans, Subscriptions)

Gateway Abstraction Layer

Gateway Implementations (Stripe, PayPal, Eupago)
Benefits:
  • ✅ Switch gateways without changing business logic
  • ✅ Support multiple gateways simultaneously
  • ✅ Test billing logic without real payment processing
  • ✅ Add new gateways without modifying core code

2. Action-Based Architecture

All business operations use the Action pattern:
// ❌ Old approach: Fat models
$product = new Product();
$product->name = 'Pro Plan';
$product->save();

// ✅ New approach: Actions
$product = app(CreateProduct::class)->execute(
    new CreateProductDto(name: 'Pro Plan')
);
Why Actions?
  • Single responsibility principle
  • Easy to test in isolation
  • Clear dependencies via constructor injection
  • Transactional integrity (automatic rollback on error)
  • Reusable across controllers, commands, jobs

3. Domain-Driven Structure

Code is organized by business domain, not technical layer:
src/
├── Products/              # Product domain
│   ├── Actions/          # Business logic
│   ├── Dto/              # Data transfer objects
│   ├── Enums/            # Type-safe enums
│   ├── Models/           # Eloquent models
│   └── ProductManager.php
├── Subscriptions/         # Subscription domain
│   ├── Actions/
│   ├── Dto/
│   ├── Enums/
│   ├── Models/
│   └── SubscriptionManager.php
└── [Other Domains...]
Benefits:
  • ✅ Easy to find related code
  • ✅ Clear separation of concerns
  • ✅ Scales well as codebase grows
  • ✅ Each domain is self-contained

Architecture Layers

Layer 1: Facade Layer

Entry point for developers:
use Eufaturo\Billing\Billing;

// Simple, clean API
Billing::products()->create(/* ... */);
Billing::subscriptions()->create(/* ... */);
Billing::gateways()->get('stripe');
Purpose:
  • Provides consistent API across all domains
  • Hides complexity from end users
  • Manages dependency resolution
  • Request-scoped memoization

Layer 2: Manager Layer

Domain managers coordinate actions:
// ProductManager.php
readonly class ProductManager
{
    public function __construct(
        private CreateProduct $createProduct,
        private UpdateProduct $updateProduct,
        private DeleteProduct $deleteProduct,
        private RestoreProduct $restoreProduct,
    ) {}

    public function create(/* params */): Product
    {
        return $this->createProduct->execute(
            new CreateProductDto(/* ... */)
        );
    }
}
Purpose:
  • Inject and coordinate actions
  • Provide friendly developer API
  • Convert parameters to DTOs
  • Keep actions focused

Layer 3: Action Layer

Single-responsibility business logic:
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);
            // ... set other properties
            $product->save();

            $this->connection->commit();

            return $product;
        } catch (Throwable $th) {
            $this->connection->rollBack();
            throw $th;
        }
    }
}
Purpose:
  • Encapsulate single business operation
  • Handle database transactions
  • Throw exceptions on failure (auto-rollback)
  • Emit domain events
  • Keep business logic testable

Layer 4: Data Layer

Models and relationships:
class Product extends Model
{
    use SoftDeletes;

    protected $table = 'billing_products';
    protected $guarded = ['*'];

    public function plans(): HasMany
    {
        return $this->hasMany(ProductPlan::class);
    }

    public function features(): BelongsToMany
    {
        return $this->belongsToMany(Feature::class, 'billing_product_features');
    }
}
Purpose:
  • Database interaction only
  • No business logic
  • Relationships and accessors
  • Type casting

The Action Pattern in Detail

Anatomy of an Action

final readonly class CreateSubscription
{
    // 1. Constructor Injection
    public function __construct(
        private ConnectionInterface $connection,
        private EventDispatcher $events,
    ) {}

    // 2. Execute Method (single entry point)
    public function execute(CreateSubscriptionDto $dto): Subscription
    {
        try {
            // 3. Begin Transaction
            $this->connection->beginTransaction();

            // 4. Business Logic
            $subscription = new Subscription();
            $subscription->billable()->associate($dto->billable);
            $subscription->plan()->associate($dto->plan);
            $subscription->type = $dto->type;
            $subscription->status = SubscriptionStatus::New;
            // ... more logic
            $subscription->save();

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

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

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

Why readonly?

Actions are declared readonly for immutability:
final readonly class CreateProduct
{
    // All properties are readonly
    public function __construct(
        private ConnectionInterface $connection,
    ) {}
}
Benefits:
  • Thread-safe
  • Prevents accidental state mutation
  • Forces stateless design
  • Better performance (no property reassignment)

The DTO Pattern

What are DTOs?

Data Transfer Objects are immutable containers for method 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 (method hell):
public function create(
    string $name,
    ?string $slug = null,
    ?string $description = null,
    bool $isVisible = true,
    bool $isPopular = false,
    bool $isDefault = false,
    array $metadata = [],
    array $features = [],
): Product {
    // 8 parameters! Hard to maintain
}
With DTOs (clean):
public function execute(CreateProductDto $dto): Product
{
    // Single parameter!
    // Type-safe
    // Auto-completion
    // Easy to extend
}
Benefits:
  • ✅ Single parameter instead of many
  • ✅ Type-safe at compile time
  • ✅ Named parameters at call site
  • ✅ Easy to add new fields (no breaking changes)
  • ✅ Self-documenting

Database Design

Table Naming Convention

All billing tables are prefixed with billing_:
billing_products
billing_product_plans
billing_subscriptions
billing_features
billing_currencies
billing_payment_gateways
billing_gateway_records
billing_transactions
...
Benefits:
  • Clear separation from app tables
  • No naming conflicts
  • Easy to identify billing-related data

Polymorphic Relationships

Many tables use polymorphic relationships:
// Gateway records can belong to any entity
Schema::create('billing_gateway_records', function (Blueprint $table) {
    $table->morphs('entity'); // entity_type, entity_id
    $table->string('external_id');
});

// Usage
$subscription->gatewayRecords()->create([
    'external_id' => 'sub_stripe123',
]);

$plan->gatewayRecords()->create([
    'external_id' => 'price_stripe456',
]);
Used For:
  • Gateway records (subscriptions, plans, customers)
  • Discounts (subscriptions, orders)
  • Transactions (subscriptions, orders)

Soft Deletes

Most models use soft deletes:
use Illuminate\Database\Eloquent\SoftDeletes;

class Product extends Model
{
    use SoftDeletes;
}
Benefits:
  • ✅ Safe deletion (can restore)
  • ✅ Preserve historical data
  • ✅ Audit trail intact
  • ✅ Relationships preserved

Dependency Injection

Constructor Injection

All dependencies are injected via constructor:
final readonly class CreateSubscription
{
    public function __construct(
        private ConnectionInterface $connection,
        private EventDispatcher $events,
        private ProrationCalculator $calculator,
    ) {}
}
Resolved Automatically:
// Laravel container resolves all dependencies
$action = app(CreateSubscription::class);

Interface Binding

Bind interfaces to implementations in service provider:
// BillingServiceProvider.php
$this->app->singleton(GatewayManager::class);
$this->app->singleton(
    GatewayManagerInterface::class,
    fn($app) => $app->make(GatewayManager::class)
);

Event System

Domain Events

Actions emit events after successful operations:
// In action
$this->connection->commit();

// Emit event AFTER commit
$this->events->dispatch(
    new SubscriptionCreated($subscription)
);
Available Events:
  • SubscriptionCreated
  • SubscriptionCancelled
  • PlanChanged
  • PaymentReceived
  • PaymentFailed
  • … and more

Listening to Events

// In EventServiceProvider
protected $listen = [
    SubscriptionCreated::class => [
        SendWelcomeEmail::class,
        ProvisionResources::class,
        NotifySlack::class,
    ],
];

Testing Architecture

Action 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')
    );

    expect($product->name)->toBe('Test Product');
});

Transaction Rollback Testing

Test that transactions rollback on error:
test('rolls back on error', function () {
    $mockConnection = $this->createConnectionInterfaceMock();

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

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

Extending the Package

Adding Custom Actions

Create your own actions following the same pattern:
namespace App\Billing\Actions;

use Eufaturo\Billing\Subscriptions\Models\Subscription;
use Illuminate\Database\ConnectionInterface;

final readonly class CustomSubscriptionAction
{
    public function __construct(
        private ConnectionInterface $connection,
    ) {}

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

            // Your custom logic here

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

Extending Managers

Managers are not final, so you can extend them:
namespace App\Billing;

use Eufaturo\Billing\Products\ProductManager as BaseManager;

class ProductManager extends BaseManager
{
    public function createWithDefaults(string $name): Product
    {
        return $this->create(
            name: $name,
            isVisible: true,
            isPopular: false,
            metadata: [
                'created_by' => auth()->id(),
            ],
        );
    }
}
Then bind your extended manager:
// AppServiceProvider
$this->app->bind(
    \Eufaturo\Billing\Products\ProductManager::class,
    \App\Billing\ProductManager::class
);

Best Practices

✅ Do

  • Use actions for all business logic
  • Keep models thin (data only)
  • Use DTOs for complex parameters
  • Wrap operations in transactions
  • Emit events after success
  • Test actions in isolation
  • Follow domain-driven structure

❌ Don’t

  • Put business logic in models
  • Call actions from other actions directly
  • Skip transaction management
  • Emit events before commit
  • Bypass managers (use Billing facade)
  • Mix domains in single file

Architecture Diagrams

Request Flow

User Request

Controller/Livewire Component

Billing::products()->create()

ProductManager

CreateProduct Action

Transaction → Product Model → Database

Commit Transaction

Emit ProductCreated Event

Return Product to Controller

Subscription Creation Flow

Billing::subscriptions()->create()

SubscriptionManager

CreateSubscription Action

    ├─→ Validate billable entity
    ├─→ Check plan eligibility
    ├─→ Calculate trial period
    ├─→ Create subscription record
    ├─→ Store gateway reference (if gateway-managed)
    └─→ Create initial transaction

Commit Transaction

    ├─→ Emit SubscriptionCreated event
    └─→ Create gateway subscription (if needed)

Return Subscription

Performance Considerations

Memoization

Managers are memoized for request-scoped singleton:
// Billing.php
public static function products(): ProductManager
{
    return once(fn() => app(ProductManager::class));
}

// First call: creates instance
$manager1 = Billing::products();

// Second call: returns cached instance
$manager2 = Billing::products();

// Same instance
$manager1 === $manager2; // true

Eager Loading

Always eager load relationships:
// ❌ N+1 queries
$products = Product::all();
foreach ($products as $product) {
    echo $product->plans->count(); // Query per product!
}

// ✅ Single query
$products = Product::with('plans')->get();
foreach ($products as $product) {
    echo $product->plans->count(); // No additional queries
}

Next Steps


Further Reading