pixelshaped / flat-mapper-bundle
Object mapper for denormalized data. Transform flat arrays (like database JOIN results) into nested, typed DTOs without the overhead of a full ORM.
Installs: 3 233
Dependents: 0
Suggesters: 0
Security: 0
Stars: 12
Watchers: 2
Forks: 1
Open Issues: 0
Type:symfony-bundle
pkg:composer/pixelshaped/flat-mapper-bundle
Requires
- php: ^8.2
- symfony/cache-contracts: ^2.5 || ^3.3
- symfony/config: ^5.4 || ^6.0 || ^7.0
- symfony/dependency-injection: ^5.4 || ^6.0 || ^7.0
- symfony/http-kernel: ^5.4 || ^6.0 || ^7.0
Requires (Dev)
- phpstan/phpstan: ^1.11
- phpunit/phpunit: ^11.1
README
Flat Mapper Bundle
Object mapper for denormalized data. Transform flat arrays (like database JOIN results) into nested, typed DTOs without the overhead of a full ORM.
The Problem
When you write efficient SQL JOINs, you get back flat, denormalized rows where parent data repeats across child records:
// Result from: SELECT author.*, book.* FROM authors LEFT JOIN books ON books.author_id = authors.id $queryResults = [ ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 1, 'book_name' => 'Travelling as a group'], ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys'], ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 3, 'book_name' => 'Coding on the road'], ['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 4, 'book_name' => 'My best recipes'], ];
But you want clean, nested DTOs for your application:
[
AuthorDTO(
id: 1,
name: 'Alice Brian',
books: [
BookDTO(id: 1, name: 'Travelling as a group'),
BookDTO(id: 2, name: 'My journeys'),
BookDTO(id: 3, name: 'Coding on the road'),
]
),
AuthorDTO(
id: 2,
name: 'Bob Schmo',
books: [
BookDTO(id: 4, name: 'My best recipes'),
]
),
]
FlatMapper does this transformation automatically, handling:
- Deduplication (one AuthorDTO per unique author despite repeated rows)
- Relationship reconstruction (grouping books under their authors)
- Nested object hierarchies (DTOs containing arrays of other DTOs)
- Type safety (strongly-typed DTOs with PHP attributes)
And it's fast. FlatMapper outperforms Doctrine entity hydration for read operations—even without N+1 queries. See benchmarks comparing FlatMapper to Doctrine entities, partial objects, and manual mapping.
Quick Start
Installation
composer require pixelshaped/flat-mapper-bundle
Basic Usage
1. Define your DTOs with attributes:
use Pixelshaped\FlatMapperBundle\Mapping\{Identifier, Scalar, ReferenceArray}; class AuthorDTO { public function __construct( #[Identifier] #[Scalar('author_id')] public int $id, #[Scalar('author_name')] public string $name, #[ReferenceArray(BookDTO::class)] public array $books, ) {} } class BookDTO { public function __construct( #[Identifier('book_id')] public int $id, #[Scalar('book_name')] public string $name, ) {} }
2. Map your flat results:
use Pixelshaped\FlatMapperBundle\FlatMapper; $flatMapper = new FlatMapper(); $authors = $flatMapper->map(AuthorDTO::class, $queryResults);
That's it! You now have properly structured AuthorDTO objects with nested BookDTO arrays.
How It Works
Mapping Attributes
FlatMapper uses PHP attributes to define how flat data maps to your DTOs:
#[Identifier] - Required
Every DTO needs exactly one identifier to track unique instances:
// As a property attribute (when you need the ID in your DTO) class AuthorDTO { public function __construct( #[Identifier] #[Scalar('author_id')] public int $id, // ... ) {} } // As a class attribute (when you only need it for internal tracking) #[Identifier('product_id')] class ProductDTO { public function __construct( #[Scalar('product_sku')] public string $sku, // ... ) {} }
#[Scalar("column_name")] - Optional
Maps a column from your result set to a scalar property. Omit if property names match column names:
class BookDTO { public function __construct( public int $id, // Looks for 'id' column #[Scalar('book_name')] public string $name, // Looks for 'book_name' column ) {} }
#[ReferenceArray(NestedDTO::class)] - For nested objects
Creates an array of nested DTOs from the denormalized data:
class AuthorDTO { public function __construct( #[Identifier('author_id')] public int $id, #[ReferenceArray(BookDTO::class)] public array $books, // Will contain BookDTO instances ) {} }
#[ScalarArray("column_name")] - For arrays of scalars
Collects scalar values (like IDs) into an array:
class CustomerDTO { public function __construct( #[Identifier('customer_id')] public int $id, #[ScalarArray('shopping_list_id')] public array $shoppingListIds, // [1, 2, 3, ...] ) {} }
#[NameTransformation] - Class-level attribute
Apply consistent naming rules to avoid repeating #[Scalar] on every property:
use Pixelshaped\FlatMapperBundle\Mapping\NameTransformation; // Add a prefix to all column lookups #[NameTransformation(columnPrefix: 'author_')] class AuthorDTO { public function __construct( #[Identifier] public int $id, // Looks for 'author_id' public string $name, // Looks for 'author_name' ) {} } // Convert camelCase to snake_case #[NameTransformation(snakeCaseColumns: true)] class ProductDTO { public function __construct( #[Identifier] public int $productId, // Looks for 'product_id' public string $productName, // Looks for 'product_name' ) {} } // Combine both #[NameTransformation(columnPrefix: 'usr_', snakeCaseColumns: true)] class UserDTO { public function __construct( #[Identifier] public int $userId, // Looks for 'usr_user_id' public string $fullName, // Looks for 'usr_full_name' ) {} }
Individual #[Scalar] or #[Identifier] attributes override class-level transformations.
Complete Examples
Nested DTOs Example
DTOs:
Input (denormalized):
$results = [ ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'], ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 2, 'book_name' => 'My journeys', 'book_publisher_name' => 'Lorem Press'], ['author_id' => 1, 'author_name' => 'Alice Brian', 'book_id' => 3, 'book_name' => 'Coding on the road', 'book_publisher_name' => 'Ipsum Books'], ['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 1, 'book_name' => 'Travelling as a group', 'book_publisher_name' => 'TravelBooks'], ['author_id' => 2, 'author_name' => 'Bob Schmo', 'book_id' => 4, 'book_name' => 'My best recipes', 'book_publisher_name' => 'Cooking and Stuff'], ]; $authors = $flatMapper->map(AuthorDTO::class, $results);
Output (nested objects):
Array ( [1] => AuthorDTO Object ( [id] => 1 [name] => Alice Brian [books] => Array ( [1] => BookDTO Object ( [id] => 1 [name] => Travelling as a group [publisherName] => TravelBooks ) [2] => BookDTO Object ( [id] => 2 [name] => My journeys [publisherName] => Lorem Press ) [3] => BookDTO Object ( [id] => 3 [name] => Coding on the road [publisherName] => Ipsum Books ) ) ) [2] => AuthorDTO Object ( [id] => 2 [name] => Bob Schmo [books] => Array ( [1] => BookDTO Object ( [id] => 1 [name] => Travelling as a group [publisherName] => TravelBooks ) [4] => BookDTO Object ( [id] => 4 [name] => My best recipes [publisherName] => Cooking and Stuff ) ) ) )
Scalar Arrays Example
DTO: ScalarArrayDTO
Input:
$results = [ ['object1_id' => 1, 'object1_name' => 'Root 1', 'object2_id' => 1], ['object1_id' => 1, 'object1_name' => 'Root 1', 'object2_id' => 2], ['object1_id' => 1, 'object1_name' => 'Root 1', 'object2_id' => 3], ['object1_id' => 2, 'object1_name' => 'Root 2', 'object2_id' => 1], ['object1_id' => 2, 'object1_name' => 'Root 2', 'object2_id' => 4], ];
Output:
Array ( [1] => ScalarArrayDTO Object ( [id] => 1 [name] => Root 1 [object2s] => Array ( [0] => 1 [1] => 2 [2] => 3 ) ) [2] => ScalarArrayDTO Object ( [id] => 2 [name] => Root 2 [object2s] => Array ( [0] => 1 [1] => 4 ) ) )
Framework Integration
Symfony
FlatMapper works out of the box with Symfony. Optionally configure for better performance:
# config/packages/pixelshaped_flat_mapper.yaml pixelshaped_flat_mapper: validate_mapping: '%kernel.debug%' # Disable validation in production cache_service: cache.app # Cache mapping metadata
Doctrine
Use with DQL queries:
$result = $entityManager->createQueryBuilder() ->select('customer.id AS customer_id, customer.name AS customer_name, shopping_list.id AS shopping_list_id') ->from(Customer::class, 'customer') ->leftJoin('customer.shoppingLists', 'shopping_list') ->getQuery() ->getResult(); $customers = $flatMapper->map(CustomerDTO::class, $result);
Pagination
FlatMapper works with Doctrine's Paginator:
$qb = $customerRepository->createQueryBuilder('customer') ->leftJoin('customer.addresses', 'address') ->select('customer.id AS customer_id, customer.ref AS customer_ref, address.id AS address_id') ->setFirstResult(0) ->setMaxResults(10); $paginator = new Paginator($qb->getQuery(), fetchJoinCollection: true); $paginator->setUseOutputWalkers(false); $customers = $flatMapper->map(CustomerWithAddressesDTO::class, $paginator);
Standalone (No Framework)
use Pixelshaped\FlatMapperBundle\FlatMapper; $flatMapper = new FlatMapper(); // Optional: configure for production $flatMapper ->setCacheService($psr6CachePool) // Any PSR-6 cache ->setValidateMapping(false); // Skip validation checks $result = $flatMapper->map(AuthorDTO::class, $queryResults);
Performance Optimization
Mapping Cache
Mapping metadata is created once per DTO and cached across requests when a cache service is configured. The first call analyzes your DTO attributes; subsequent calls use the cached mapping.
Pre-cache Mappings
Avoid creating mappings on hot paths by pre-caching during deployment:
$dtoClasses = [CustomerDTO::class, OrderDTO::class, ProductDTO::class]; foreach ($dtoClasses as $class) { $flatMapper->createMapping($class); }
This is optional. Mappings are created automatically when calling map() if not already cached.
Disable Validation in Production
Validation checks ensure your DTOs are configured correctly but add a little overhead. Disable in production:
$flatMapper->setValidateMapping(false);
Or in Symfony:
pixelshaped_flat_mapper: validate_mapping: '%kernel.debug%' # true in dev, false in prod
Why Not Just Use...?
Doctrine Entities
FlatMapper is significantly faster for read operations (see benchmarks):
- ~2x faster execution time
- 40-60% less memory usage
- No lazy-loading surprises
Using full Doctrine entities for reads also:
- Risks coupling your templates/views to your domain model
- Loads entity metadata and change tracking overhead
- Can trigger lazy-loading and N+1 queries (even with proper JOINs, proxies add overhead)
FlatMapper gives you lightweight, read-only DTOs optimized for queries.
Doctrine's NEW Operator
Doctrine can create DTOs directly in DQL:
$query = $em->createQuery('SELECT NEW CustomerDTO(c.name, e.email, a.city) FROM Customer c JOIN c.email e JOIN c.address a'); $customers = $query->getResult(); // array<CustomerDTO>
Limitation: Only supports scalar properties. You can't have:
- Arrays of nested DTOs (
#[ReferenceArray]) - Arrays of IDs or other scalar arrays (
#[ScalarArray]) - Complex object graphs
FlatMapper solves this by handling denormalized data at any nesting level.
Other Object Mappers
Most object mappers transform nested arrays (like JSON) to objects:
- mark-gerarts/automapper-plus - Maps entities to DTOs (normalized data)
- jolicode/automapper - Maps normalized objects/arrays
- sunrise-php/hydrator - Maps normalized arrays to objects
These don't handle denormalized data where:
- Parent information repeats across multiple rows
- Relationships need to be reconstructed from flat results
- One row doesn't equal one object
PARTIAL Objects + Manual Mapping
You could use Doctrine's PARTIAL objects then map to DTOs, but:
- No indication whether an object is fully loaded
- Two-step process (entity hydration + DTO mapping)
- Higher complexity than direct flat-to-DTO mapping
Contributing
Found a bug or have a suggestion? Please open an issue or submit a pull request.
Know of an alternative that solves similar problems? Let us know—we'd love to reference it here!
License
This bundle is released under the MIT License. See the LICENSE file for details.