analogue/orm
Analogue is a flexible data-mapper ORM for PHP and Laravel. It separates domain models from mappers and repositories, offering fluent query building, eager-loaded relations, collections and clean persistence without the active-record constraints of Eloquent.
Install
composer require analogue/orm
Laravel auto-discovery registers Analogue\ORM\AnalogueServiceProvider and the Analogue facade. On older Laravel versions, add both to config/app.php manually. Publish config:
php artisan vendor:publish --provider="Analogue\ORM\AnalogueServiceProvider"
config/analogue.php is where the DBAL connection name, custom mapper bindings, and cache settings live. Read it before anything else — it is the main integration surface.
First file to open — the config, then the README's Entity/Mapper/Relationships sections. The mental model to adopt immediately: an Entity is a plain-ish PHP object, a Mapper is the table gateway, and store() is an upsert. There is no ActiveRecord and no $model->save().
Minimal end-to-end
use Analogue\ORM\Entity;
class User extends Entity
{
protected $attributes = ['id', 'name', 'email'];
protected $casts = ['id' => 'integer'];
}
$mapper = Analogue::mapper(User::class);
$user = new User;
$user->name = 'Ada';
$user->email = '[email protected]';
$mapper->store($user); // INSERT — $user->id populated from the identity map
$same = $mapper->find($user->id);
That is the whole loop: construct/assign, store(), find()/get(). First real use case: replacing an Eloquent model with a domain object that does not extend a framework base class, wrapped in a repository.
Repositories around mappers. The natural Analogue workflow is one repository per aggregate, injecting the manager (or resolving Analogue) and exposing intent-revealing methods:
class UserRepository
{
public function find(int $id): ?User
{
return Analogue::mapper(User::class)->find($id);
}
public function active(): Collection
{
return Analogue::mapper(User::class)
->where('active', true)
->orderBy('created_at', 'desc')
->get();
}
public function save(User $user): User
{
Analogue::mapper(User::class)->store($user);
return $user;
}
}
Relationships as methods on the entity. Declare the graph once, then use it from the mapper:
class Post extends Entity
{
protected $attributes = ['id', 'user_id', 'title'];
public function author()
{
return $this->belongsTo(User::class, 'user_id');
}
public function comments()
{
return $this->hasMany(Comment::class);
}
}
Accessing $post->author returns a lazy Proxy; the parent row is loaded on first property access.
Eager loading and nested graphs. This is how you keep query counts sane:
Analogue::mapper(Post::class)
->with('author', 'comments.author')
->where('status', 'published')
->get();
Storing graphs. Assign the relation and let store() cascade:
$user->posts = [$draft, $published];
$mapper->store($user); // writes user, then the related posts
Store children individually if you need finer control over write ordering.
Query building is fluent, execution is lazy. Everything returns a query builder until you call get(), first(), find(), or all(). Reuse a scoped builder across repository methods rather than rebuilding it.
Transactions. Wrap multi-entity writes:
Analogue::transaction(function () use ($mapper, $user, $posts) {
$mapper->store($user);
foreach ($posts as $post) {
$mapper->store($post);
}
});
Custom mappers as extension points. Bind a mapper subclass per entity in config/analogue.php ('mappers' => [User::class => UserMapper::class]) to add default scopes, soft-delete filtering, or a shared query API. This is the Analogue equivalent of Eloquent's local scopes.
Lifecycle events. Analogue fires creating, created, updating, updated, saving, saved, deleting, deleted. Use saving to stamp created_at/updated_at — they are not automatic.
Not a unit of work. Despite the Data Mapper framing, store() writes immediately; there is no flush() batching changes for you. Analogue::transaction() is your safety net, not hydration tracking.
Identity map surprises. Within a request, $mapper->find(1) returns the same instance every time. If you update a row via raw DBAL, the in-memory entity stays stale — detach it from the manager or fetch it through a fresh resolution path. Likewise, two find() calls returning "different" objects will actually be the same reference, which breaks naive assertNotSame tests.
Lazy proxies cause N+1. Any loop touching ->relation without with() is a query per row. Check with() on every collection query in code review; treat a bare relation access inside foreach as a smell.
List every column in $attributes. It defines what the entity will populate and mass-assign. Omit a column and you get silent nulls, not exceptions. Casts (integer, boolean, datetime, json/array) go in $casts; without a cast, DBAL hands you raw strings.
No timestamps, no soft deletes. Both are conventions you implement yourself (events or custom mapper). Don't assume Eloquent parity — it is the most common source of "why isn't updated_at changing".
Collections are not arrays. get() returns Analogue\ORM\System\Collection; use toArray() or iterate. count($collection) and empty($collection) behave differently than on an array in some cases.
Version pinning is the real blocker. Last release was April 2020 and it targets doctrine/dbal 2.x, which conflicts with Laravel 9+ (DBAL 3). Practically: PHP 7.4–8.0 and Laravel 5–8. If you're on a modern stack, budget for a fork or vendor patch before designing anything around it. With zero dependents and no releases in years, treat it as a legacy or educational choice, not a foundation for new build.
Schema tooling from metadata is a bootstrap aid, not a migration system. Analogue can derive tables from entity definitions, but it won't handle index tuning, data migrations, or safe rollbacks. Own your migrations in Laravel.
Debugging. Inspect what a mapper will run with $mapper->getQuery() before executing, and enable DBAL SQL logging on the underlying connection. Because relationships resolve lazily through proxies, stack traces point at the proxy, not at your foreach — when a query looks unexplained, grep for the relation name rather than the property.
Config cache. php artisan config:cache freezes analogue.connection; changing the DB name in .env without clearing config produces confusing "table not found" errors against the wrong database.
Extension points to remember: custom mapper classes (behaviour, scopes), a custom Entity base class (shared casts/events), and manager bindings in the container (swap in a fake manager or a test double for repository tests). Entities being plain objects means domain tests need no database at all — keep that separation, it's the main payoff of the package.
How can I help you explore Laravel packages today?