Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Laravel Overridable Model Laravel Package

genealabs/laravel-overridable-model

Override Eloquent model methods and properties at runtime without touching vendor code. Define per-model overrides in your app to customize package models, swap behavior, or inject your own logic—keeping third-party code untouched and upgrades painless.

View on GitHub
Deep Wiki
Context7

Getting Started

Understand what it actually buys you before running composer require. This package solves exactly one problem: a vendor package ships Acme\Blog\Models\Post and hard-codes that FQCN in relationships, scopes, factories, and policies. You need App\Models\Post — with extra columns, casts, and your own User relation — to be what actually gets instantiated at runtime, without forking vendor code. If you don't have that problem today, close the tab; there's nothing else here.

The archive status is a day-one fact, not a footnote. Last release 2024‑05‑11, Laravel 11 only, zero dependents, no CVE channel. Read the source before you install it — it's roughly one trait and a small service provider. If after reading it you think "I could own this," you probably should, in app/Support/, behind your own registry class. If you install it anyway, pin the exact version.

Install and publish.

composer require genealabs/laravel-overridable-model
php artisan vendor:publish --tag=config

Service provider is auto-discovered. Open the published config and read the key name literally — the README is your only contract, and it won't get updated.

Two sides of the seam. The vendor model opts in; your app model is a plain subclass:

// vendor side — trait on the base
namespace Acme\Blog\Models;

use GeneaLabs\LaravelOverridableModel\Traits\OverridableModel;
use Illuminate\Database\Eloquent\Model;

class Post extends Model
{
    use OverridableModel;
}
// app side — no trait, just a subclass
namespace App\Models;

class Post extends \Acme\Blog\Models\Post
{
    protected $casts = [
        'published_at' => 'immutable_datetime',
        'meta'         => 'array',
    ];
}

Register the override in one of two places. Config map for static, per-environment registration:

// config/overridable-model.php
'overrides' => [
    \Acme\Blog\Models\Post::class => \App\Models\Post::class,
],

Or imperatively, which is what you want if boot order or conditions matter:

public function boot(): void
{
    \Acme\Blog\Models\Post::overrideWith(\App\Models\Post::class);
}

First verification is non-negotiable, and it must be a test, not a browser check. The dominant failure mode is that the override doesn't take effect and you get the base model with no exception, no log line, and wrong application behaviour that surfaces in production a week later.

public function test_blog_post_override_resolves(): void
{
    $this->assertSame(\App\Models\Post::class, get_class(\Acme\Blog\Models\Post::create([])));
    $this->assertSame(\App\Models\Post::class, get_class(app(\Acme\Blog\Models\Post::class)));
    $this->assertSame(\App\Models\Post::class, get_class($blog->posts()->first()));
}

Write that before you write a single line of subclass behaviour. Then write it again as a data-driven test over the whole config map (see Implementation Patterns).

Typical first use case, end to end: vendor Post relation returns Acme\Blog\Models\Post; you need meta as an array cast, a published_at immutable cast, and author() pointing at your User. Trait on the base (or vendor already added it), subclass in App\Models, one overrideWith() line, contract test. Under an hour — unless the vendor factory hard-codes the class, in which case add another hour.

Implementation Patterns

Make the subclass a true drop-in, then diverge deliberately. The entire payoff is that $blog->posts()->first() returns your class without any call site changing. That only holds if your subclass agrees with the base on $table, $primaryKey, $incrementing, and the fillable/guarded contract. Extend protected properties rather than replacing them:

class Post extends \Acme\Blog\Models\Post
{
    protected $fillable = [...parent::$fillable, 'meta'];  // check the base first — it may be guarded, not fillable
}

If the base uses $guarded = [], adding $fillable silently changes mass-assignment semantics. Read the base class before you touch either.

Extend methods with parent::, never copy them. Vendor upgrades flow through the parent chain; duplicated vendor logic rots quietly.

public function scopePublished($query)
{
    return parent::scopePublished($query)->whereNotNull('meta');
}

This is the pattern that makes the override survivable. The moment you paste vendor scope logic into your subclass, you've forked — and this package's whole reason to exist is avoiding that.

Config map vs. overrideWith() — pick one, document it in the repo README, and enforce it in review. Config map: static, greppable, one file, easy for a new engineer to see every override at a glance. overrideWith() in a provider: conditional (feature flag, tenant, edition), or when ordering matters. Mixing them across a codebase is how you get "the override is in the config but the provider re-registers it differently in production."

Wrap it in your own registry. This is the single highest-leverage decision you'll make with an archived dependency:

namespace App\Support;

class ModelOverrides
{
    public static function register(array $map): void
    {
        foreach ($map as $base => $override) {
            $base::overrideWith($override);
        }
    }
}

Call sites and the service provider talk to ModelOverrides, not to the package trait. When you eventually reimplement the ~100-line trait yourself (likely, given the archive status), it's a one-file change and nothing else notices.

The contract test is a first-class artifact. Make it data-driven off the same source of truth the registry uses:

/** @test */
public function every_registered_override_resolves(): void
{
    foreach (config('overridable-model.overrides') as $base => $override) {
        $this->assertSame($override, get_class(new $base));
        $this->assertSame($override, get_class($base::query()->make()));
        $this->assertSame($override, get_class(app($base)));
    }
}

Run it in CI with php artisan config:cache applied, not just against the fresh config. The cached-config path is where overrides disappear.

Relationships are where the override pays off and where it breaks. Vendor's Post::author() returns BelongsTo(Acme\Blog\Models\User::class). That's their User, not yours. Two honest options: override User too (now you own two surfaces against vendor upgrades), or override the relationship method in your subclass:

public function author(): BelongsTo
{
    return $this->belongsTo(\App\Models\User::class, 'user_id');
}

Don't half-do it. A graph where Post is your class but Post->author is the vendor's User produces instanceof surprises in policies, form requests, and API resources — and those surprises are hard to trace back to a class-substitution decision.

Check the vendor factory before you trust a single test. Factories that do Post::factory() and then new static or $model::new() inherit the override correctly. Factories that do new Acme\Blog\Models\Post inside definition() or a configure() hook do not — you'll be asserting against the base class and feel productive the whole time. Grep the vendor factory source. If it hard-codes, publish and override the factory too.

Policies need an assertion, not a hope. AuthServiceProvider maps App\Models\Post::class => PostPolicy::class. That works when the resolved instance is your subclass and fails silently when it isn't. Include one get_class() assertion in a policy test for each overridden model, or you've tested the policy against the vendor's class and learned nothing.

Keep one "seam test" per overridden model that goes through the vendor's public API. Your unit tests on the subclass prove the subclass works. Only a test through $blog->posts(), or a vendor repository/service method, proves the override is actually wired. That's the path that silently regresses.

Document the import convention in onboarding. The recurring bug in these setups is a new engineer (or an IDE auto-import) writing use Acme\Blog\Models\Post; and calling Post::create(). Configure IDE auto-import to prefer App\ and put one line in the onboarding doc: "never import from the vendor namespace; if you see it in a diff, flag it."

Gotchas and Tips

Silent fallback is the failure mode, and it's the default. Not-registered config, wrong key name, provider booted after the first query, overrideWith() inside a conditional that didn't fire — every one of these gives you the base model with no error. There is no assertion, no exception, no log. Mitigate with the contract test, and additionally in the service provider:

foreach (config('overridable-model.overrides') as $base => $override) {
    throw_unless(is_subclass_of($override, $base), LogicException::class, "$override must extend $base");
    throw_unless(class_exists($override), LogicException::class, "missing $override");
}

That catches typo'd FQCNs and non-extending overrides at boot rather than at 3am.

Boot order matters more than you'd expect. If anything in a register() method or an early provider queries Post, the override may not be registered yet. Put overrideWith() in boot() of a provider that runs early, or in a register() if the package resolves lazily through the container. Verify with app(Post::class) after boot in a test — the container resolution path and the Eloquent static path are not always the same code path, and the package handles them differently.

Config caching destroys closures. Any override registered inside a Closure in a config file evaporates under php artisan config:cache. Config values must be plain ::class string constants — no closures, no env() outside config files, no new. Register conditional overrides in a service provider instead. Test with php artisan config:cache && php artisan test in CI; this is precisely the "worked on my machine" class of bug.

Octane and long-lived workers. Class substitution that leans on aliasing or static state persists across requests in a worker. Verify the override resolves identically on request 1 and request 500 of the same worker. If you run Octane, this alone justifies a smoke test in staging.

IDE and static analysis go blind. PHPStan/Larastan and go-to-definition see Acme\Blog\Models\Post in the docblock; you're calling an app-only method on the returned instance. Expect false positives. Fix with @method / @property docblocks on the base, or a PHPStan stub file that maps the base to the override. Don't disable the rule — the rule is right, the runtime is the liar.

Exact-class comparisons break. get_class($post) === Acme\Blog\Models\Post::class now fails. instanceof checks still pass (your class is a subclass). Grep your own code and any vendor code you control for get_class(...) === and === Acme\Blog\Models\Post::class patterns before rollout. Type hints declaring : Acme\Blog\Models\Post are fine — covariance holds.

Relationship signature mismatches surface as TypeError under strict types. If the base declares public function author(): BelongsTo and you override it returning something with a different generic, or a collection is typed against the base while first() yields a subclass with a divergent relation, you get a TypeError far from the override. Override relationship methods wholesale, keeping the parent's return types intact.

Migrations and schema are not handled. The package swaps the class, not the table. Your subclass must point at the same $table (or you own a migration and a data-migration plan). If the vendor computes $table in the constructor, do not override the constructor — you'll break the table resolution and get confusing "table not found" errors that look like a connection problem.

Don't override the whole vendor namespace. Eight overrides to change two behaviours turns this package into a maintenance tax: every override is a surface you now own against vendor upgrades, because your parent:: calls reach into vendor code that the archived package won't keep compatible for you. Budget each override as ongoing cost, not as one line of config.

Laravel 12+ is unverified. Last release targets Laravel 11. If you're on 12, add a Composer conflict on the next major version so upgrades fail loudly instead of resolving into broken behaviour, and run the contract test on every framework bump.

The trait is the extension point — and the exit door. Need a guard exception on unregistered overrides, a resolution-order rule, or logging when an override resolves? Subclass the trait or wrap the resolution in your ModelOverrides class. That's also your reimplementation path: MIT license, small surface, ~100 lines. Copying it into app/Support/ behind ModelOverrides is a legitimate — arguably correct — endgame for an archived dependency sitting in your model-instantiation path.

Assign a named owner. An archived dependency in the object-instantiation path with no security-patch channel has exactly one safety net: a person who owns the fork, the contract test, and the Laravel upgrade matrix. If nobody will sign up for that, don't install it — read the source for the pattern and implement the twelve lines you actually need.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
shitoudev/permissionmanager
johannesclimacus/laravel-tui
hocvt/seotools
vitalybaev/sypexgeo
amphibee/themosis-ignition
gito-me/fast-excel
frankyso/laravel-ipaymu
geolize-studio/laravel-chile
boomerangui/chat
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console