Backend Conventions
Why the FlexPoint API is structured the way it is — the vendor seam, enums, casts, and first-party extension points.
This page answers a recurring question: "Some apps/flex-hub-api/app subdirectories
(Adapters, Contracts, Enums, Casts, Rules) look non-standard —
shouldn't we refactor them toward Laravel best practices?"
Short answer: they already are the Laravel best practices. Every one of these folders is either a framework-standard directory or a first-party, documented extension point. The only thing that ever causes confusion is the naming of the vendor seam — explained in full below. Nothing here is a refactor target.
The vendor seam is the service container, by the book
The one architectural boundary we keep is the vendor seam (ports & adapters),
implemented with Laravel's service container, service providers, and contracts —
exactly the "swap the payment gateway via .env" pattern.
| Certification exercise | This codebase |
|---|---|
PaymentGateway interface | a port in app/Contracts/Vendor/{Capability}/ (e.g. PricingProvider) |
StripeGateway / BraintreeGateway impls | an adapter in app/Adapters/{Capability}/ (e.g. FlexRateAdapter, MlmPriceMyLoanAdapter) |
| Bound in a service provider | VendorServiceProvider binds each contract to the configured adapter |
Chosen via .env | config/vendors.php reads a *_DRIVER env var |
$gateway->charge(...) | the consumer injects the contract and calls it (e.g. $this->pricing->quote(...)) |
Why "Provider" and "Adapter", not "Service"
In a ports-and-adapters seam the conventional pairing is Provider (the port
/ interface) + Adapter (the concrete implementation). We do not rename
these to *Service because app/Services/ already holds a different concept —
cross-action coordination and read models — so a rename would trade a small
naming question for a genuine collision. The consumer never knows which vendor it
is talking to; that is the whole point.
A vendor swap in practice
// app/Actions/Pricing/RequestPricingQuote.php
public function __construct(private readonly PricingProvider $pricing) {}
public function handle(array $input /* … */): PricingQuote
{
$result = $this->pricing->quote(new PricingRequest(/* … */));
// …
}Switching PRICING_DRIVER=mlm ⇄ PRICING_DRIVER=flexrate in .env changes
which adapter the container resolves. This Action — and every other consumer —
is untouched. Adding a brand-new vendor is purely additive: a new adapter class,
one config/vendors.php entry, one .env flag, one line in
VendorServiceProvider::vendorBindings().
Enums are native PHP enums used as model casts
app/Enums/* are PHP backed enums (e.g. LoanStatus), applied as Eloquent
attribute casts:
// app/Models/Loan.php
protected function casts(): array
{
return [
'status' => LoanStatus::class,
'purpose' => LoanPurpose::class,
// …
];
}So the enum is the model attribute — the status column round-trips through
the enum on read and write.
- Methods like
isTerminal()andcanTransitionTo()belong on the enum. They are state-machine guards, not validation rules. Co-locating the legal-transition map with the cases keeps it the single source of truth. - We do not turn these into Eloquent lookup tables. A DB-backed lookup for a fixed, code-controlled set would add joins and runtime queries for values that never change.
Casts and Rules are first-party extension points
| Directory | What it is |
|---|---|
app/Casts/ | Custom attribute casts implementing CastsAttributes (e.g. AsDeclarations). Output of php artisan make:cast. |
app/Rules/ | Custom validation rules implementing ValidationRule (e.g. MlmPasswordComplexity). Output of php artisan make:rule. |
app/Contracts/ | Application-owned interfaces (the vendor ports live under Contracts/Vendor/). |
app/Concerns/ · app/Support/ | Reusable traits and plain helper/catalog classes. |
Takeaways
Adapters,Contracts,Enums,Casts,Rules,Concerns, andSupportare intentional and idiomatic. Do not propose refactoring them.- The vendor seam is the service-container DI pattern;
Provider= port,Adapter= implementation, selected by a*_DRIVERenv var. - Enums stay PHP enums (with methods), cast onto models — not lookup tables.
- New cross-action coordination logic belongs in
app/Services/{Area}/(the formerapp/Workflows/engine now lives underapp/Services/Workflow/).