Tutorial: a validated "create customer" flow
This tutorial builds one real use case end to end — turning an untrusted request payload into a created customer — using Schema, DTO, and Result together. It's framework-agnostic and PHP 7.4-compatible. By the end you'll have a controller-shaped function that never trusts raw input and never uses exceptions for expected failures.
If you haven't installed Maybe yet, see Getting Started. The finished code mirrors examples/schema-dto.php.
Step 1 — Describe the input with a Schema
Start at the boundary. Instead of reaching into $_POST and checking fields by hand, declare the shape once:
use Maybe\Schema\Schema;
$customerSchema = Schema::shape([
'name' => Schema::string()->trimmed()->min(2),
'email' => Schema::string()->trimmed()->regex('/^[^@\s]+@[^@\s]+\.[^@\s]+$/'),
'age' => Schema::int()->min(18),
]);safeParse returns a Result: Ok with cleaned data (already trimmed), or Err with a ValidationErrorBag.
$result = $customerSchema->safeParse($request);Step 2 — Give the data a type with a DTO
A Result<array, …> is fine, but downstream code deserves a named type, not a loose array. A DTO binds the schema to an immutable object:
use Maybe\DTO\DTO;
use Maybe\Schema\ObjectSchema;
use Maybe\Schema\Schema;
final class CustomerData extends DTO
{
/** @var string */
public $name;
/** @var string */
public $email;
/** @var int */
public $age;
private function __construct(string $name, string $email, int $age)
{
$this->name = $name;
$this->email = $email;
$this->age = $age;
}
public static function schema(): ObjectSchema
{
return Schema::shape([
'name' => Schema::string()->trimmed()->min(2),
'email' => Schema::string()->trimmed()->regex('/^[^@\s]+@[^@\s]+\.[^@\s]+$/'),
'age' => Schema::int()->min(18),
]);
}
protected static function fromValidated(array $validated)
{
return new self($validated['name'], $validated['email'], $validated['age']);
}
}
$dtoResult = CustomerData::fromArray($request); // Result<CustomerData, ValidationErrorBag>Step 3 — Put business rules in a service returning Result
Structural validation ("is this an int ≥ 18?") is the schema's job. Business rules ("is this email already taken?") aren't — they live in a service that returns a Result:
use Maybe\Result\Result;
final class CustomerService
{
public function create(CustomerData $data): Result
{
if ($this->emailExists($data->email)) {
return Result::err('email_already_registered');
}
return Result::ok(['id' => 42, 'email' => $data->email]);
}
private function emailExists(string $email): bool
{
return $email === 'taken@example.com';
}
}Step 4 — Compose the pipeline with andThen
andThen chains the DTO step into the service step, short-circuiting if validation already failed. No nested if, no try/catch:
$service = new CustomerService();
$outcome = CustomerData::fromArray($request)
->andThen(fn (CustomerData $data): Result => $service->create($data));$outcome is now a Result<array, mixed> that captures every way this can end: invalid input (a ValidationErrorBag), a business rule failure (a string code), or success (the created customer).
Step 5 — Render one response at the boundary
match() forces you to handle success and failure in one place. Validation errors render themselves with describe(); you never pull path/message out by hand:
$response = $outcome->match(
function (array $customer): array {
return ['status' => 201, 'body' => $customer];
},
function ($error): array {
if ($error instanceof \Maybe\Schema\ValidationErrorBag) {
return ['status' => 422, 'body' => ['errors' => $error->describe()]];
}
return ['status' => 409, 'body' => ['error' => $error]];
}
);Send $response['status'] and json_encode($response['body']) from whatever framework you're in — the flow above doesn't depend on any of them.
What you built
- Input is validated at the boundary and never trusted afterward.
- Validated data travels as a typed
CustomerData, not a raw array. - Every outcome — invalid input, business-rule failure, success — is a value, handled in one
match(). - No exceptions for expected failures; no
null; no nested conditionals.
Next steps: browse the Recipes for more patterns, see Case Studies for email/SAP/contract scenarios, or keep everything readable with Object Calisthenics.