Skip to main content
Lunar’s Eloquent models are extended using Laravel’s native mechanisms, plus two small Lunar-specific seams for the cases natives don’t cover.

Overview

In v1, a Lunar model could be replaced entirely with a consumer’s subclass, registered through ModelManifest. v2 removes this. Lunar’s models keep a single concrete class identity throughout the framework: Lunar\Core\Models\Product is always Lunar\Core\Models\Product, never a consumer’s subclass standing in for it.
Model class substitution — registering App\Models\Product extends Lunar\Core\Models\Product and having Lunar hand back instances of it everywhere — is no longer supported. Keeping two live class identities for one logical model broke native Eloquent mechanisms in subtle ways: event listeners could fire twice, externally registered global scopes were silently dropped, and every relationship, morph lookup, and factory resolution needed special-cased forwarding. Every one of substitution’s real use cases is covered by one of the recipes below.
Every recipe on this page is exercised against real Lunar models in the package’s own test suite, so each is guaranteed to work against the current release.

Add a relationship

Use Laravel’s dynamic relationships from a service provider’s boot method. This works on any Lunar model without needing to replace it.
See the Laravel documentation on dynamic relationships for more information.

Add a method

Use a model macro, registered from a service provider’s boot method.
Use a real closure, not an arrow function. Macroable rebinds the macro to the model instance via bindTo, which arrow functions ignore.

Add a column

Add the column with a migration; nothing else is needed. Lunar’s models are unguarded, so a new column is immediately a readable and writable attribute.

Cast an added column

Register the cast from a service provider’s boot method with addCasts(), backed by Lunar\Core\Models\Concerns\HasExtendableCasts, a trait every Lunar model uses. It accepts the same values as a model’s own casts() method, including a custom cast class — the way to attach an accessor/mutator pair to an added column.
addCasts() merges into whatever casts the model already declares, so it never has to be aware of Lunar’s own casts. It only makes sense for columns the consumer has added; Lunar’s own columns are already cast correctly.

Constrain every query

Use a global scope, registered from a service provider’s boot method.

Add an optional query scope

Global scopes are always on. For a scope that should only apply when called — the equivalent of a local scope method, but registered from outside the model class — use addLocalScope(), a method every Lunar model gets from Models\Base.
Once registered, a scope is indistinguishable from a native one — callable on the query builder or as a static entry point, and chainable with everything else:
A scope registered against Product is callable only on Product queries; calling it on Order::query() throws BadMethodCallException, the same as a mistyped native scope. If a registered name collides with one of Lunar’s own local scopes, macros, or query builder methods, the native one always wins — a registered scope can never shadow a built-in.
This works even for Lunar\Core\Models\Collection, which uses its own nested-set query builder rather than Lunar’s default Lunar\Core\Models\Builders\Builder. Both compose the same underlying resolution logic, so Collection::addLocalScope() behaves identically to any other model.

Type safety for registered scopes

A registered scope is resolved at runtime, so there’s nothing static for an IDE or PHPStan to read — the same limitation that already applies to macros and dynamic relationships. Declare the signature once in a stub file the application owns and excludes from autoload:
PhpStorm and PHPStan merge the docblock onto the real Product class, so Product::featured()->priorityOver(5)->get() completes and type-checks. For PhpStorm, barryvdh/laravel-ide-helper writes a similar guarded, never-executed redeclaration into _ide_helper_models.php; since its generator cannot see runtime-registered closures, add the @method lines to that file’s Product block by hand. For PHPStan or Psalm, point stubFiles at a stub declaring the same lines.

React to lifecycle events

Use a model observer or an event listener, exactly as with any other Eloquent model.

Override behavior

Model verb methods — $order->cancel(), $cart->createOrder() — are thin delegations to an action contract resolved from the container. To change what happens, bind a different implementation of the contract in a service provider; the verb picks it up automatically. See Extending Orders and Extending Carts for the contracts available on each model.
This is not a model concern: swapping behavior always goes through the container, never through subclassing a model.

Serialization

For array or JSON output, use append() or makeVisible() per instance, or wrap the model in an API Resource in the consuming application’s own layer.

What replaced model class substitution

The Lunar\Core\Models\Contracts\* interfaces that v1 used to resolve a model to its (possibly substituted) class no longer exist, since there is nothing left to resolve — a Lunar model is always the Lunar class. Route model binding, relationship loading, and the morph map all resolve directly to Lunar\Core\Models\* classes.
Rather than modifying fields on Lunar’s core models, create separate Eloquent models in the consuming application and relate them to Lunar’s models. This keeps custom data separate and avoids conflicts during upgrades.