AI 资讯
Eloquent Events vs Domain Events: Why the Framework Hook Isn't Enough
Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub You wire a listener to Eloquent's saved event on the Order model. When an order is saved, send the confirmation email. It works in the demo. Then a support ticket lands: a customer got two confirmation emails for one purchase, and another got a refund receipt for an order that was never refunded. You dig in. The double email came from a background job that touched updated_at on the order to bump a cache. The bogus receipt came from an admin editing the shipping address, which saved the model, which fired saved , which ran a listener that assumed "saved means the order changed state." None of that was the customer's intent. All of it was persistence. That's the whole problem in one sentence. saved tells you a row hit the database. It does not tell you what happened in your business. What Eloquent events actually fire on Eloquent dispatches creating , created , updating , updated , saving , saved , deleting , deleted , and a few more. Every one of them is tied to a persistence operation on a single model. They fire because you called save() , update() , create() , or delete() , not because a business rule was satisfied. Here is the shape most teams start with: <?php namespace App\Models ; use Illuminate\Database\Eloquent\Model ; class Order extends Model { protected static function booted (): void { static :: updated ( function ( Order $order ): void { // "the order changed, email the customer" OrderMailer :: confirmation ( $order ); }); } } The listener assumes updated means "something the customer cares about changed." It doesn't. updated fires for any dirty column: a cached counter, a nightly touch() , an admin fixing a typo in t
AI 资讯
Query Objects in PHP: Rich Filtering Without Leaking SQL Into the Domain
Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub You ship a list endpoint. GET /orders . It returns the customer's orders, newest first. Clean. Then product wants a status filter. Then a date range. Then "only orders over 100 euros". Then pagination. Then sort by total, descending. Six months later the controller signature reads like a tax form, and the repository method that backs it is findByCustomerAndStatusAndMinTotalBetweenDatesOrderedBy(...) with nine parameters, three of them nullable. So you "fix" it. You let the caller pass a Doctrine QueryBuilder into the repository. Or worse: the use case starts assembling WHERE clauses as strings because that was the fastest way to add the next filter on a Friday. Now your application layer knows about table aliases and SQL operators. The domain speaks SQL, and the whole point of having a repository interface is gone. There is a shape that holds: a query object the domain builds, and an adapter that translates it into SQL. The caller describes what it wants in domain terms. The adapter decides how to fetch it. The leak, concretely Here is the version that grows out of control. Every new filter is another nullable parameter. public function search ( ?string $customerId , ?string $status , ?DateTimeImmutable $from , ?DateTimeImmutable $to , ?int $minTotalCents , string $sortBy = 'placedAt' , string $direction = 'DESC' , int $page = 1 , int $perPage = 20 , ): array ; Nine parameters, half of them nullable, two of them ( sortBy , direction ) carrying raw column names that map straight to SQL. Add a filter and the signature grows again. Every caller has to remember positional order. And $sortBy is a column name leaking through the port: t
AI 资讯
Persisting One Aggregate Across Multiple Tables, ORM-Agnostic
Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub You have an Order . It owns line items and a shipping address. In the database that is three tables: orders , order_items , order_addresses . The aggregate is one thing in the domain and three rows-with-children in storage. Now look at what most codebases do with that. The service loads the order, loops the items, saves each one in its own statement. Halfway through, a unique-constraint violation throws. The order header is already committed. Two items are in. One address is missing. You have a row in orders that no part of the system considers valid, and no single place to point at when you ask how it got there. The aggregate is supposed to be a consistency boundary. Saving its parts in separate, independently-committing operations breaks that boundary on the way to disk. This post is about closing it: one repository, one transactional save across every table, and a read path that rebuilds the whole object from rows without leaking the ORM into the domain. The aggregate the domain sees The domain class has no idea it lives in three tables. It holds its children directly and guards their invariants. <?php declare ( strict_types = 1 ); namespace App\Domain\Order ; use App\Domain\Shared\Money ; final class Order { /** @var list<LineItem> */ private array $items ; private function __construct ( private readonly OrderId $id , private readonly CustomerId $customerId , array $items , private Address $shipTo , private OrderStatus $status , ) { if ( $items === []) { throw new InvalidOrder ( 'order needs an item' ); } $this -> items = array_values ( $items ); } public static function place ( OrderId $id , CustomerId $customerId , array $it
AI 资讯
Modeling Money in PHP: Your Own Value Object vs brick/money Behind a Port
Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub Open a PHP REPL and type 0.1 + 0.2 . You get 0.30000000000000004 . Now imagine that drift sitting in a total column on an invoice. The bug report does not say "IEEE 754 rounding." It says "the customer was charged one cent too much and finance wants to know why." Money is not a number. It is an amount paired with a currency, and it follows rules a float does not know about. You cannot add 10 USD to 10 EUR. You cannot store 19.99 in a binary fraction and expect it back. You cannot round a third of a cent without deciding how to round it, and that decision belongs to your business, not to your storage engine. This post builds a small Money value object that gets the currency safety right, then shows when to stop hand-rolling and wrap brick/money behind a domain port instead. The point of the port is the same point as every port: the rounding rules live in your domain, and the library stays swappable. Why a float is the wrong type Three separate problems hide inside float $amount . The first is representation. A float is a binary fraction. Decimal amounts like 0.10 have no exact binary form, so they round on every store and load. Sum a few thousand line items and the error compounds into real cents. The second is the missing currency. A bare number cannot tell you whether 1000 means ten dollars or one thousand yen. Two amounts in different currencies are not comparable, but the type system lets you add them anyway. The third is the rounding rule. When you split 10.00 across three line items, you get 3.333... per item. Someone has to decide who absorbs the leftover cent. A float makes that decision silently and inconsistently. The fix
AI 资讯
Hand-Rolled Mappers vs AutoMapper: Keeping the PHP Domain Pure at the Boundary
Book: Decoupled PHP — Clean and Hexagonal Architecture for Applications That Outlive the Framework Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools Me: xgabriel.com | GitHub You add a column to a table. discount_cents , nullable, default zero. A small migration, a five-minute ticket. The next morning a teammate pings you: orders placed overnight have a discount of null where the domain expected zero, and the total calculation now throws a TypeError deep inside a value object. You trace it back. A reflection-based mapper copied the new column straight onto a property the domain class did not declare, then the next read pulled it back as null and handed it to Money . Nobody wrote the line that connected discount_cents to the domain. The mapper guessed, and the guess leaked the table's shape into a class that was supposed to know nothing about tables. This is the case against automatic mapping at the persistence boundary. A hand-rolled mapper is more typing. It is also the seam that stops the database schema from reaching into your domain. What the boundary is for In a hexagonal layout the domain Order knows about line items, currencies, and the rule that an order needs at least one item. It does not know there is a orders table with a discount_cents column. The repository adapter is the only place those two worlds touch, and the mapper is the translator that stands at that seam. A reflection or annotation-driven mapper collapses the seam. It reads the persistence shape, walks the domain object's properties, and copies whatever names line up. When the two shapes match, it feels free. When they drift, the drift travels silently across the boundary you built it to protect: a renamed column, a new field, a type that does not round-trip. The whole point of the adapter is that the domain and the table can cha