Most Laravel codebases that grow painful have the same shape underneath: important concepts represented as loose strings and associative arrays, validated at the controller, and then re-checked defensively at every layer below because nobody trusts what arrived. Modern PHP has the tools to stop that, and adopting them is mostly a refactoring exercise rather than a rewrite.
Enums replace the string constants nobody enforces
A status column holding 'pending', 'approved' or 'rejected' as raw strings is three valid values and an unbounded number of typos. A backed enum makes the set closed, gives it behaviour, and turns an invalid value into an error at the boundary instead of a mystery row six months later.
enum InspectionStatus: string
{
case Draft = 'draft';
case Submitted = 'submitted';
case Approved = 'approved';
case Rejected = 'rejected';
// Behaviour belongs with the type, not in a service that switches on strings.
public function isTerminal(): bool
{
return in_array($this, [self::Approved, self::Rejected], true);
}
public function canTransitionTo(self $next): bool
{
return match ($this) {
self::Draft => $next === self::Submitted,
self::Submitted => in_array($next, [self::Approved, self::Rejected], true),
default => false,
};
}
}
class Inspection extends Model
{
protected function casts(): array
{
return ['status' => InspectionStatus::class, 'submitted_at' => 'immutable_datetime'];
}
}The transition method is the part that pays for itself. Once state changes must go through a type that knows which transitions are legal, the sprawl of scattered if-statements checking status strings collapses into one place — and match without a default becomes a compile-time reminder when you add a case.
Value objects for the things that are not strings
A money amount is not a float, an email address is not a string, and a date range is not two nullable columns. Each of those pairs an invariant with data, and a readonly value object is the cheapest place to enforce it. Construct it once at the boundary, and every function downstream can stop validating.
final readonly class Money
{
public function __construct(
public int $minorUnits, // never a float. ever.
public Currency $currency,
) {
if ($minorUnits < 0) {
throw new InvalidArgumentException('Money cannot be negative');
}
}
public function add(self $other): self
{
if ($this->currency !== $other->currency) {
throw new CurrencyMismatch($this->currency, $other->currency);
}
return new self($this->minorUnits + $other->minorUnits, $this->currency);
}
}
// A custom cast keeps the database representation out of the domain.
class MoneyCast implements CastsAttributes
{
public function get($model, $key, $value, $attributes): Money
{
return new Money((int) $value, Currency::from($attributes['currency']));
}
public function set($model, $key, $value, $attributes): array
{
return ['amount_minor' => $value->minorUnits, 'currency' => $value->currency->value];
}
}Where to start on an existing codebase
- Convert status and type columns to backed enums first. Highest value, lowest risk, and the casts make it mechanical.
- Turn money into a value object next. Float arithmetic on currency is a bug your finance team will eventually find.
- Type every property and return value in new code; add types to old code when you touch it, not in a dedicated sweep.
- Run static analysis at a fixed level in CI and ratchet it upward one level at a time rather than aiming for the maximum immediately.
- Push validation to the boundary — form requests and DTOs — and delete the defensive checks underneath once the type guarantees it.
Every defensive check deep in your service layer is a type you did not create at the boundary.
What changes in practice is not elegance, it is the class of bug that reaches production. Typos in status strings, currency mixed in an addition, a null date silently treated as zero — these stop being possible rather than becoming less likely. That is the difference worth the refactoring: not fewer bugs, but entire categories that the code can no longer express.