opportus/object-mapper
Opportus ObjectMapper is a PHP library for mapping data between objects and arrays with configurable strategies. It helps transform DTOs and entities cleanly, reducing repetitive boilerplate in Laravel and plain PHP projects.
Install and sanity-check first.
composer require opportus/object-mapper
Requirements are modest (PHP 7.4+/8.x era), MIT licensed, no framework coupling — it is a standalone library, so there is no service provider to discover.
Where to look first, in this order:
src/ObjectMapper.php — the single entry point (map()), tells you the whole public contract in ~100 lines.src/Point/ — SourcePoint / TargetPoint value objects. Everything in this library is expressed as a point (a class, a property, a method, an argument).src/Route/ — a Route is a source point → target point link, built with RouteBuilder.src/Strategy/ and src/Control/ — where you extend it.tests/ — with only 18 stars and a last release in May 2022, the test suite is the real documentation. Read it before trusting the README.First use case: map an inbound payload object onto a typed command/DTO where names line up.
use Opportus\ObjectMapper\ObjectMapper;
use Opportus\ObjectMapper\Point\PointFactory;
use Opportus\ObjectMapper\Route\RouteBuilder;
use Opportus\ObjectMapper\Route\RouteCollection;
$points = new PointFactory();
$routes = new RouteCollection();
$routes->addRoute(
(new RouteBuilder($points))
->setSourcePoint($points->createSourceClassPoint(CreateUserRequest::class))
->setTargetPoint($points->createTargetClassPoint(CreateUserCommand::class))
->getRoute()
);
$mapper = new ObjectMapper($routes);
$command = $mapper->map($request, CreateUserCommand::class);
Treat the snippet above as the shape of the API — verify exact method names against tests/ in the tagged version you install. Nothing is auto-wired, so map() throws if no route covers the pair; that failure mode is your first debugging signal.
1. Declare routes once, at bootstrap. The route collection is a schema, not a runtime decision. Build it in a factory or container definition and reuse the mapper as a singleton:
// e.g. a small factory class
final class ObjectMapperFactory
{
public function __invoke(): ObjectMapper { /* build routes, return mapper */ }
}
Rebuilding routes per request wastes the only real cost this library has (reflection over points).
2. Map by explicit route pairs, not by convention. The library's value proposition is explicit source→target routes; resist the urge to build a generic "map anything to anything" wrapper on top. One route per (source class, target class) pair keeps failures loud and traceable.
3. Use it at the boundaries, not in the domain. Good fits:
Poor fits: deep object-graph hydration (see gotchas), or mapping inside tight loops.
4. Extend with strategies for non-scalar coercion. Scalars typically copy straight across; DateTimeImmutable, enums, and value objects usually need a strategy so the mapper knows how to convert. Implement the strategy interface, register it on the mapper, and keep one strategy per concern (date, money, enum) so they stay testable in isolation.
5. Use controls for conditional behavior — skip-if-null, field allow/deny, depth limits. Keep controls declarative and stateless; anything that depends on request state should be passed via the mapping context, not stored on the control.
6. Test the mapping, not the mapper. A route table plus round-trip tests (source → target, assert every target property) is cheap and catches the majority of breakage when you rename a property:
$command = $mapper->map($request, CreateUserCommand::class);
self::assertSame('[email protected]', $command->email);
7. Integration tip: in Laravel, bind the configured ObjectMapper as a singleton and inject it into the few actions/form-request handlers that need it. Do not add a facade or a global helper — the library has no static state and it's a shame to introduce some.
Maintenance risk is the headline gotcha. Last release 2022-05-25, 18 stars, no visible activity. Pin the exact version in composer.json, and budget for forking or vendoring if PHP 8.3/8.4 deprecations land in the reflection code. Do not put this on a critical path you can't own.
Routes are exact-type keyed. A route declared for CreateUserRequest will not serve a subclass unless you register it. If map() unexpectedly reports "no route", check the declared type of the object you passed, not the runtime shape.
Nested objects are not recursive by default. Mapping A → B where B holds a C requires a route for C too, or the property is left untouched. This is the most common "why is my property empty" cause. Enumerate the object graph up front.
Uninitialized typed properties bite. Because targets are typed objects, a skipped property stays uninitialized and explodes later at first read, far from the mapper. Assert full initialization in tests (get_object_vars or a assertObjectHasProperty sweep) rather than discovering it in production.
Null vs missing. Decide explicitly whether a null source means "write null" or "leave untouched" and encode that in a control. Mixed semantics across routes is the second-most-common bug.
Renames and case differences need explicit routes. Don't rely on fuzzy name matching — if the library offers a convention-based fallback, prefer explicit property points anyway; grep-ability beats magic when the package is unmaintained.
Debugging procedure: dump the route collection and confirm (a) the source point class, (b) the target point class/property, (c) the strategy attached. 90% of failures are a mismatch in one of those three. If a strategy silently does nothing, confirm it was registered — strategies are not auto-discovered.
Extension points worth knowing: the strategy interface (type conversion) and the control interface (conditional/structural decisions) are the two designed seams. Anything you need beyond those — lazy loading, circular-reference handling, collection-of-objects mapping — you are building yourself on top; treat it as your code, not the package's.
Performance: reflection-per-mapping is fine for request boundaries and unacceptable per-row in a bulk import. If you're mapping 10k rows, map the batch shape once (a collection DTO holding scalars) rather than the mapper 10k times.
How can I help you explore Laravel packages today?