Eloquent factories create test models. The Factory pattern is broader: it gives a complicated construction rule one home. Builders help when callers fill optional fields gradually. Neither pattern is a reason to wrap every constructor in another class. They earn their place when an object has rules that must be true before it reaches the rest of the application.
Shipping labels make the distinction concrete. A controller receives strings from an HTTP request. A carrier client needs an address, a supported country, a positive weight and a shipping service. If that data travels through the application as an array, every consumer has to remember key names, defaults and validation. One consumer will eventually forget.
Arrays are a weak contract
This is a familiar starting point:
public function store(Request $request, CarrierClient $carrier): RedirectResponse
{
$label = $carrier->createLabel($request->all());
return to_route('shipments.show', $label->shipmentId());
}
It hides several decisions. Is country a two-letter ISO code? Is weight_grams allowed to be zero? What happens when service is absent? The carrier client either repeats HTTP validation, accepts invalid input, or silently chooses defaults. None of those choices is visible at the boundary.
The goal is not to make invalid input impossible to submit. Laravel's Form Request still handles that job and returns useful field-level errors. The goal is to make invalid domain requests impossible to pass to the use case. After construction, a ShippingLabelRequest should be safe for an action, job, command or API client to consume.
Put local invariants in value objects
Start with values that have meaning beyond one method. A raw string does not tell a reader whether it is a country code, an e-mail address or a service name. These objects do, and their named constructors become the one place that enforces local invariants.
<?php
declare(strict_types=1);
namespace App\Shipping;
use DomainException;
final readonly class CountryCode
{
private const array Supported = ['DE', 'GB', 'PL'];
private function __construct(public string $value) {}
public static function from(string $value): self
{
$normalized = strtoupper(trim($value));
if (! in_array($normalized, self::Supported, true)) {
throw new DomainException("Unsupported destination country [{$normalized}].");
}
return new self($normalized);
}
}
final readonly class Weight
{
private function __construct(public int $grams) {}
public static function fromGrams(int $grams): self
{
if ($grams < 1 || $grams > 30_000) {
throw new DomainException('Shipment weight must be between 1 and 30000 grams.');
}
return new self($grams);
}
}
readonly prevents accidental mutation after construction. It does not make an object valid by itself: the named constructors do that work. Do not turn every database column into a value object. CountryCode pays for itself because supported destinations and normalisation matter in several places. A one-off nullable note field usually does not.
The composite request can now state its rules without knowing anything about HTTP:
<?php
declare(strict_types=1);
namespace App\Shipping;
use DomainException;
final readonly class ShippingAddress
{
public function __construct(
public string $recipient,
public string $lineOne,
public string $postalCode,
public string $city,
public CountryCode $country,
) {
if (mb_strlen(trim($recipient)) < 2) {
throw new DomainException('A recipient name is required.');
}
}
}
final readonly class ShippingLabelRequest
{
public function __construct(
public ShippingAddress $address,
public Weight $weight,
public string $service,
public ?string $reference = null,
) {
if (! in_array($service, ['economy', 'express'], true)) {
throw new DomainException("Unsupported shipping service [{$service}].");
}
}
}
The boundary is deliberate: it rejects a service the business cannot fulfil, but it does not ask the database whether a customer has credit. Construction rules are deterministic facts about this value. Time-dependent or persistence-based policy belongs in an action or domain service, where dependencies are explicit and testable.
A factory translates one boundary into another
The factory's responsibility is translation, not a second controller. It takes the already validated HTTP shape and creates domain objects. It is the right home for normalisation such as trimming a reference, mapping a form field name to a domain name, or converting an integer into Weight.
<?php
declare(strict_types=1);
namespace App\Shipping;
final class ShippingLabelRequestFactory
{
/**
* @param array{
* recipient: string,
* address_line_1: string,
* postal_code: string,
* city: string,
* country: string,
* weight_grams: int,
* service: string,
* reference?: string|null
* } $input
*/
public function fromValidated(array $input): ShippingLabelRequest
{
return new ShippingLabelRequest(
address: new ShippingAddress(
recipient: trim($input['recipient']),
lineOne: trim($input['address_line_1']),
postalCode: trim($input['postal_code']),
city: trim($input['city']),
country: CountryCode::from($input['country']),
),
weight: Weight::fromGrams($input['weight_grams']),
service: $input['service'],
reference: filled($input['reference'] ?? null) ? trim($input['reference']) : null,
);
}
}
There is deliberate overlap with request validation. The Form Request protects the public interface and reports errors in Laravel's standard format. The value objects protect every caller, including a queued job that never passed through that request. Do not let fromValidated() become permission to pass untrusted arrays elsewhere; its name documents a precondition.
Here is the Laravel boundary and the use case that follows it:
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Actions\CreateShippingLabel;
use App\Http\Requests\StoreShippingLabelRequest;
use App\Shipping\ShippingLabelRequestFactory;
use Illuminate\Http\RedirectResponse;
final class ShippingLabelController
{
public function store(
StoreShippingLabelRequest $request,
ShippingLabelRequestFactory $factory,
CreateShippingLabel $createShippingLabel,
): RedirectResponse {
$label = $createShippingLabel->handle($factory->fromValidated($request->validated()));
return to_route('shipments.show', $label->shipmentId());
}
}
<?php
declare(strict_types=1);
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
final class StoreShippingLabelRequest extends FormRequest
{
public function rules(): array
{
return [
'recipient' => ['required', 'string', 'max:120'],
'address_line_1' => ['required', 'string', 'max:120'],
'postal_code' => ['required', 'string', 'max:20'],
'city' => ['required', 'string', 'max:120'],
'country' => ['required', Rule::in(['DE', 'GB', 'PL'])],
'weight_grams' => ['required', 'integer', 'min:1', 'max:30000'],
'service' => ['required', Rule::in(['economy', 'express'])],
'reference' => ['nullable', 'string', 'max:80'],
];
}
}
Keeping the controller this small matters. It does HTTP work, calls one translator and delegates the business operation. The action can be reused by an admin form, import command and queue worker without receiving a Request or a vague array.
Use a builder for progressive construction
A factory is best when all input exists at once. A builder is useful when construction genuinely happens in stages: an import reads a recipient first, then an address, then shipping options. Fluent calls show which choices have been made and build() remains the only exit.
<?php
declare(strict_types=1);
namespace App\Shipping;
use LogicException;
final class ShippingLabelRequestBuilder
{
private ?ShippingAddress $address = null;
private ?Weight $weight = null;
private ?string $service = null;
private ?string $reference = null;
public function forAddress(ShippingAddress $address): self
{
$this->address = $address;
return $this;
}
public function weighing(Weight $weight): self
{
$this->weight = $weight;
return $this;
}
public function usingService(string $service): self
{
$this->service = $service;
return $this;
}
public function withReference(?string $reference): self
{
$this->reference = $reference;
return $this;
}
public function build(): ShippingLabelRequest
{
if ($this->address === null || $this->weight === null || $this->service === null) {
throw new LogicException('Address, weight and service are required before building a label request.');
}
return new ShippingLabelRequest(
address: $this->address,
weight: $this->weight,
service: $this->service,
reference: $this->reference,
);
}
}
For a test, the builder reads better than a nine-key array:
$request = (new ShippingLabelRequestBuilder())
->forAddress(new ShippingAddress('Ada Lovelace', '10 Code Street', '00-001', 'Warsaw', CountryCode::from('PL')))
->weighing(Weight::fromGrams(750))
->usingService('express')
->withReference('order-1001')
->build();
Do not register a mutable builder as a singleton in Laravel's container. Its state would leak between resolutions in a long-running worker. Create it where needed, or bind it as transient. Also avoid a builder with twenty methods that merely mirrors a data model: a command object with a clear constructor, or a dedicated form, is often more honest.
Test the construction contract directly
The important tests do not assert private implementation details. They prove the promises callers rely on: normalisation, rejection of invalid values and a complete request after build().
<?php
use App\Shipping\CountryCode;
use App\Shipping\ShippingAddress;
use App\Shipping\ShippingLabelRequestBuilder;
use App\Shipping\ShippingLabelRequestFactory;
use App\Shipping\Weight;
use DomainException;
use LogicException;
it('normalizes validated input into a shipping label request', function () {
$request = (new ShippingLabelRequestFactory())->fromValidated([
'recipient' => ' Ada Lovelace ',
'address_line_1' => '10 Code Street',
'postal_code' => '00-001',
'city' => 'Warsaw',
'country' => 'pl',
'weight_grams' => 750,
'service' => 'express',
'reference' => ' order-1001 ',
]);
expect($request->address->recipient)->toBe('Ada Lovelace')
->and($request->address->country->value)->toBe('PL')
->and($request->reference)->toBe('order-1001');
});
it('rejects an unsupported country even outside HTTP validation', function () {
CountryCode::from('US');
})->throws(DomainException::class);
it('does not build an incomplete request', function () {
(new ShippingLabelRequestBuilder())
->forAddress(new ShippingAddress('Ada Lovelace', '10 Code Street', '00-001', 'Warsaw', CountryCode::from('PL')))
->weighing(Weight::fromGrams(750))
->build();
})->throws(LogicException::class);
The first test is a unit test; it needs no database or HTTP kernel. Add a feature test for the Form Request when the endpoint matters, and a separate test for CreateShippingLabel with a fake carrier client. That split keeps a construction failure easy to diagnose.
When the patterns are the wrong tool
Do not introduce a factory and builder because a constructor has three parameters. A small immutable object with a clear constructor is already a good API. Likewise, a Form Request alone is enough when data never leaves the controller and has no domain meaning. Factories can become disguises for business workflows; if construction needs inventory, credit or database queries, name that operation as an action or service instead.
The alternative to a builder is usually a named constructor or an explicit command. CreateShipment::forOrder($order, $service) is more direct when an order supplies all required data. A builder is valuable only when callers really choose optional values step by step. The alternative to custom value objects is Laravel validation and casts, often sufficient at a single persistence boundary.
Use the smallest boundary that preserves meaning. When arrays begin to carry business rules and optional construction paths, factories, builders and value objects turn implicit agreements into code that both Laravel and the next maintainer can trust.