Design Patterns And Data Architecture
Mapper And Transformer Pattern
A mapper or transformer converts data from one shape into another. In PHP applications, this appears when database rows become objects, domain objects become response models, provider payloads become application DTOs, CSV rows become import commands, and internal values become public API fields. The names mapper and transformer are often used loosely; the important idea is that conversion rules have an owner.
Mapping looks mechanical, but it often carries business and contract decisions. A database stores money as integer pence; an API returns a string amount. A provider sends snake_case; your domain object uses typed properties. A CSV import has empty strings that should become null. A public response keeps an old field name for compatibility even after an internal rename. If every controller performs these conversions inline, behavior drifts.
A mapper should make conversion visible, testable, and reviewable. It should not become a hidden place for major business decisions. If mapping code decides whether a user is allowed to see an invoice, the boundary is mixed. If it formats a date consistently for the API, it is doing useful mapping work.
Mapper Versus Transformer
Teams use the words differently. In many codebases, mapper suggests converting between persistence and domain shapes, such as rows to objects. Transformer often suggests converting an application object into an output representation, such as a JSON resource. Some teams use either word for both jobs.
Pick names that reveal direction. UserRowMapper, InvoiceResponseTransformer, WebhookPayloadMapper, and CsvOrderImportMapper are clearer than DataMapper or Transformer. Direction matters because converting from raw input to domain is not the same as converting domain to public output.
A mapping class should have a narrow source and target. If one mapper converts users, invoices, payments, and reports, it is probably a general utility rather than a focused boundary.
One-Way And Two-Way Mapping
Some mappings are naturally one-way. A public API response transformer should usually convert internal data to output, not accept the public response and rebuild the domain object. The output may omit internal fields, combine values, rename fields for compatibility, or include links that do not belong in the domain. Reversing that transformation would be lossy and misleading.
Persistence mapping may be two-way, but the two directions still deserve separate thought. Turning a row into an object is not the same as preparing an update statement. Inserts may need generated IDs, updates may need version checks, and partial updates may allow only selected columns. A single bidirectional mapper can work when both directions are simple, but do not force symmetry where the source and target have different rules.
When direction is unclear, split the mapper and name each conversion after its source and target. Reviewers should not have to infer which side is authoritative.
A Row Mapper Example
This example maps a database-like row into a typed object.
<?php
declare(strict_types=1);
final readonly class Product
{
public function __construct(
public int $id,
public string $name,
public int $pricePence,
) {
}
}
final class ProductRowMapper
{
/** @param array{id: int|string, name: string, price_pence: int|string} $row */
public function fromRow(array $row): Product
{
return new Product(
id: (int) $row['id'],
name: trim($row['name']),
pricePence: (int) $row['price_pence'],
);
}
}
$mapper = new ProductRowMapper();
$product = $mapper->fromRow(['id' => '7', 'name' => ' Notebook ', 'price_pence' => '1299']);
echo $product->id . ': ' . $product->name . ' costs ' . $product->pricePence . PHP_EOL;
// Prints:
// 7: Notebook costs 1299
The mapper owns type conversion and trimming. In production, it should also handle missing or malformed fields according to the repository's error policy. Do not silently coerce every bad value into something plausible.
Response Transformers
A response transformer shapes internal data for output. It can keep API contracts stable, omit sensitive fields, format dates, choose public enum names, and include links or metadata.
For example, an InvoiceResponseTransformer might convert an invoice object into an array with id, status, amount, currency, issued_at, and download_url. It should not expose internal accounting flags, database IDs not meant for clients, or private notes.
Response transformers should be tested like public contract code. If a field is renamed internally, the transformer may intentionally preserve the public field. If an enum gains a new value, the transformer should decide whether clients receive it, receive a fallback, or require a versioned API change.
Import Mappers
Import mappers convert external input into application commands or DTOs. CSV, spreadsheet, XML, JSON Lines, and provider exports often contain loose types and inconsistent missing-value conventions.
An import mapper should normalize field names, trim values, parse dates, convert money safely, and report row-level errors with enough context to fix the input. It should not perform the whole import workflow. Validation, duplicate detection, transaction handling, and persistence may belong in separate services or actions.
Keep raw input available for diagnostics when safe, but do not log secrets or personal data unnecessarily. Mapping failures should point to row number, field name, and reason rather than a generic "bad data" message.
Provider Payload Mappers
Adapters often include mapping. A payment provider may return nested JSON with provider-specific statuses, amounts, and risk fields. A mapper can convert that payload into PaymentAuthorizationResult with application status, amount, provider reference, and retry category.
This protects the application from provider names. If provider status insufficient_funds should become PaymentDeclined, that mapping belongs at the integration boundary. If another provider uses card_declined, the rest of the application can still see the same application outcome.
Provider mappers need tests for every important status. Unknown statuses should fail safely or map to an explicit unknown category. Do not silently treat unknown provider results as success.
Mapping Is A Contract Decision
Mapping decisions affect compatibility. Changing a response transformer from created_at to createdAt can break clients. Changing money from string pounds to integer pence can break calculations. Changing timezone format can break sorting or parsing.
Internal mappers have more freedom because all callers deploy together. Public API, webhook, queue, and stored-event mappers need stronger compatibility rules. Versioning may be required.
Document units, timezone, null handling, enum behavior, and unknown-field behavior for important boundaries. A mapper is a good place to enforce those decisions consistently.
Where Mapping Should Live
Small local mapping can stay local. A controller that maps two fields for a private page may not need a class. Create a mapper when conversion is reused, complex, public, risky, or needs tests.
Repositories may use row mappers internally. Adapters may use provider mappers internally. Presenters may use response transformers. The mapper's location should follow the boundary it protects.
Avoid a global Mapper service that knows every conversion in the application. That centralizes unrelated rules and creates merge conflicts. Focused mapper classes are easier to review.
Error Handling
Mapping can fail. Missing keys, invalid dates, malformed money, unknown enum values, unsupported versions, and unexpected provider shapes should not become random defaults.
For trusted database rows, a mapping failure may indicate a bug or corrupted data and should be loud. For user imports, mapping failure may be a row-level validation result. For provider payloads, mapping failure may be an integration error that should alert operations.
Choose failure behavior based on source trust and recovery path. Do not use one catch-all exception for every mapping problem if callers need different responses.
Testing Mapping Rules
Mapping tests should use representative fixtures. Include valid data, missing optional values, nulls, malformed values, unknown enum values, and boundary dates. For response transformers, assert exact output shape and sensitive-field omission.
Avoid tests that only instantiate the mapper. The value of a mapper test is proving field-by-field behavior. When mappers protect public contracts, snapshot-style checks can be useful if reviewed carefully, but they should not hide unexplained changes in massive fixtures.
Property names and formats deserve explicit assertions. A passing PHP type check does not prove the public JSON field name is correct.
Common Failure Modes
One failure mode is scattered mapping. Every controller builds slightly different arrays for the same object, so API responses drift.
Another failure mode is silent coercion. Casting every value to string or int may hide bad input. A product with missing price should not quietly become zero unless that is an explicit rule.
A third failure mode is mixing authorization into mapping. A transformer can omit fields based on a prepared visibility decision, but it should not secretly decide policy by querying permissions inside output formatting.
A fourth failure mode is mapping too early. If raw invalid input is converted before validation, the system may lose the evidence needed to produce useful errors.
Review Criteria
When reviewing a mapper, ask what source and target it connects. Check field names, types, units, timezones, nulls, enums, unknown values, and sensitive fields. Check whether the mapper belongs at the right boundary.
Check tests against the real boundary: database rows, decoded provider payloads, CSV rows, JSON output arrays, or serialized messages. Review compatibility when output crosses process or client boundaries.
What To Check
Before moving on, make sure you can:
- explain mapper and transformer as conversion boundary patterns
- name mappers by source and target direction
- distinguish row mapping, response transformation, import mapping, and provider payload mapping
- keep mapping separate from business authorization and workflow orchestration
- handle malformed and unknown values deliberately
- test field names, units, dates, nulls, enums, and sensitive-field omission
- treat public and queued output mapping as contract code
After this lesson, you should be able to find repeated conversion logic in a PHP project, extract it into a focused mapper or transformer, and verify that the conversion is correct instead of assuming it is mechanical.
Practice
Task: Map A Row To An Object
A query returns ['id' => '5', 'email' => ' ADA@example.com ', 'active' => '1'].
Design a mapper that creates a user read model.
Requirements
Name the mapper, target class, normalization rules, and failure behavior for missing keys.
Check your work
The mapper should own conversion instead of scattering casts through controllers.
Show solution
The mapper casts id to int, trims the email, and converts active using an explicit rule such as '1' or 1 meaning true. Missing required keys should throw a mapping exception because a trusted query returned an unexpected shape.
Controllers should receive the read model from a repository and should not repeat row casts or column names.
Task: Review A Transformer Boundary
Three API controllers each build a user JSON array manually. One includes deleted_at, another omits avatar_url, and another formats dates differently.
Propose a transformer boundary.
Requirements
Name the transformer, output fields, compatibility rule, and tests.
Check your work
The answer should produce one public response contract for the same user view.
Show solution
It should omit deleted_at unless the public API explicitly exposes that concept. If existing clients depend on a field name, preserve it until a versioned change is planned.
Tests should assert the exact field set, date format, null handling for missing avatar, and absence of internal fields. Controllers can then call the transformer instead of building arrays manually.
Task: Test Mapping Rules
A provider sends payment statuses approved, insufficient_funds, expired_card, and sometimes unknown new values.
Plan mapper tests for converting provider payloads into application payment results.
Requirements
Include known statuses, unknown status, malformed amount, missing provider reference, and retry category.
Check your work
The answer should not treat unknown provider statuses as success.
Show solution
Use fixtures for approved, insufficient_funds, and expired_card. Assert that approved becomes a successful authorization result with provider reference and amount, while the declined statuses become application decline results with stable public codes.
An unknown provider status should map to an explicit unknown or integration-failure outcome, not success. A malformed amount should fail mapping loudly because money precision matters. A missing provider reference on an approved payment should also fail unless the provider contract truly allows it.
Retry category should be tested separately. Declines are permanent user-facing outcomes; provider unavailable or timeout payloads should become temporary categories where retry is safe only with idempotency.