How to Build Inherited Model Settings in Laravel Without Fallback Bugs

Design Laravel settings that inherit across companies and branches, distinguish missing values from null, and prevent unsafe tenant overrides.

LaravelPublished
On this page

A company changes the footer printed on its receipts. Most branches should use the new text, a few branches should retain their own text, and one branch should intentionally print no footer at all. An administrator also needs to change the minimum checkout amount without deploying the application.

The difficult part is not storing receipt.footer = "Thank you". It is preserving three different intentions: inherit the parent value, override it, or explicitly suppress it. If your read logic treats a missing setting as null, a branch can unexpectedly inherit text it meant to hide. If it stores every effective value as a branch override, later company changes never reach branches that should inherit them.

This guide designs that behavior with native Laravel first, including its limits. Only then does it consider a dedicated settings package. The examples assume an application with saved Company and Branch Eloquent models and a Branch::company() relationship.

Quick Answer / TL;DR: Model an override as the presence of a stored key, not merely a non-null value. Resolve branch → company → global explicitly; a missing key continues to the parent, while a stored null stops inheritance. For a few fixed fields, model columns can be enough. A reusable settings layer becomes useful when many models and keys need the same type, validation, and cache rules.

#First Decide Whether You Need a Settings System

Laravel already has three reasonable ways to represent a value, depending on who owns it and how often its shape changes.

RequirementStart withWhy
One value per deploymentLaravel configurationFiles and .env are easy to review and deploy
A few known fields on a branchEloquent columnsDatabase constraints and normal model updates are explicit
A small group of preferences owned by one modelJSON-cast attributeKeeps related preferences on the same record
Many keys across global, company, and branch scopesAn overrides tableSeparates value inheritance from model schema

Changing the live configuration repository is not persistent settings storage:

1config(['checkout.minimum_cents' => 7500]);
2 
3// Available through config() in this application runtime,
4// but not stored for future requests.

Laravel's config() and configuration cache solve a deployment and runtime configuration problem. Here we are modeling editable business preferences that are read deliberately for a particular company or branch. They should not automatically overwrite the application's configuration repository.

#The Simple Native Solution: Use Model Columns

If the only custom value is a checkout threshold, a nullable column may be the clearest design:

1// In a migration that modifies the branches table:
2Schema::table('companies', function (Blueprint $table) {
3 $table->unsignedInteger('minimum_order_cents')->nullable();
4});
5 
6Schema::table('branches', function (Blueprint $table) {
7 $table->unsignedInteger('minimum_order_cents')->nullable();
8});

Use an integer number of minor currency units rather than a float for an amount that requires exact arithmetic. With a non-null global default and an optional company-level column, a branch can inherit through the null coalescing operator:

1$minimumCents = $branch->minimum_order_cents
2 ?? $branch->company->minimum_order_cents
3 ?? 10000;

For this field, null means inherit, and a checkout minimum cannot be explicitly disabled. That is a valid business rule. There is no reason to introduce an abstract settings table for it.

However, receipt.footer has a different requirement. A branch may intentionally suppress the footer. If null means inherit, you cannot also use it to mean “print nothing.” You need another state, such as a separate override flag, a mode column, or the presence of a row in another table.

The important design decision is what null means for each field. Make that decision before picking an API or package.

#Represent the Three States Explicitly

For a growing catalog of preferences, a row-per-override table makes the distinction natural:

Branch rowStored JSON valueMeaning
No receipt.footer rowNot applicableInherit company or global footer
receipt.footer row"Branch welcome"Use this branch's text
receipt.footer rownullDeliberately show no footer

In this table, null in the last row means the JSON literal null is stored, not SQL NULL. That allows a database lookup to distinguish “there was no row” from “the row says null.”

Create a small custom table if this is the extent of your requirements:

1<?php
2 
3use Illuminate\Database\Migrations\Migration;
4use Illuminate\Database\Schema\Blueprint;
5use Illuminate\Support\Facades\Schema;
6 
7return new class extends Migration {
8 public function up(): void
9 {
10 Schema::create('preference_overrides', function (Blueprint $table) {
11 $table->id();
12 $table->string('scope', 191);
13 $table->string('key', 191);
14 $table->longText('value'); // JSON-encoded, never SQL NULL
15 $table->timestamps();
16 $table->unique(['scope', 'key']);
17 });
18 }
19 
20 public function down(): void
21 {
22 Schema::dropIfExists('preference_overrides');
23 }
24};

For this example, scopes are global, company:12, and branch:47. They are internal identifiers chosen by the application, not user input. The table stores only explicit overrides; it does not copy inherited defaults into every branch.

#Resolve a Setting Without Losing Its Source

A resolver should return more than a value when an administrator needs to understand or reset inheritance. It should preserve whether a row was found and which scope supplied it.

The following service needs no package-specific API. Save it as app/Support/PreferenceResolver.php:

1<?php
2 
3namespace App\Support;
4 
5use Illuminate\Support\Facades\DB;
6 
7final class PreferenceResolver
8{
9 /**
10 * @param list<string> $scopes Most specific scope first.
11 * @return array{found: bool, value: mixed, source: ?string}
12 */
13 public function resolve(string $key, array $scopes, mixed $default = null): array
14 {
15 $rows = DB::table('preference_overrides')
16 ->where('key', $key)
17 ->whereIn('scope', $scopes)
18 ->pluck('value', 'scope');
19 
20 foreach ($scopes as $scope) {
21 if ($rows->has($scope)) {
22 return [
23 'found' => true,
24 'value' => json_decode($rows->get($scope), true, 512, JSON_THROW_ON_ERROR),
25 'source' => $scope,
26 ];
27 }
28 }
29 
30 return ['found' => false, 'value' => $default, 'source' => null];
31 }
32}

Use it after loading and authorizing the branch. The ordered chain is deliberate: a branch wins over its company, and the company wins over the application-wide value.

1use App\Support\PreferenceResolver;
2 
3$chain = [
4 'branch:'.$branch->getKey(),
5 'company:'.$branch->company_id,
6 'global',
7];
8 
9$result = app(PreferenceResolver::class)->resolve(
10 'receipt.footer',
11 $chain,
12 'Thank you for shopping',
13);
14 
15$footer = $result['value'];
16$owner = $result['source']; // Useful for an "Inherited from company" label.

This uses one query for all candidate scopes and checks key presence, not value truthiness. A stored null, false, 0, or empty string can all be deliberate overrides. PHP's ??, empty(), or a truthy check on the decoded value would erase some of these distinctions.

The resolver also has a predictable default: only when no scope has the key does it return the supplied fallback. Defaults are not automatically persisted, and a change to the company does not require rewriting every branch record.

#Writing an Override Is Different from Removing One

To suppress the company footer, persist the JSON literal null for the branch. For a simple native implementation, upsert() is preferable to a read-then-insert sequence when requests may race:

1use Illuminate\Support\Facades\DB;
2 
3$now = now();
4 
5DB::table('preference_overrides')->upsert([
6 [
7 'scope' => 'branch:'.$branch->getKey(),
8 'key' => 'receipt.footer',
9 'value' => json_encode(null, JSON_THROW_ON_ERROR),
10 'created_at' => $now,
11 'updated_at' => $now,
12 ],
13], ['scope', 'key'], ['value', 'updated_at']);

If the branch administrator chooses Use company default, delete that row instead:

1DB::table('preference_overrides')
2 ->where('scope', 'branch:'.$branch->getKey())
3 ->where('key', 'receipt.footer')
4 ->delete();

That action is not equivalent to storing null. Deleting the row enables inheritance again; storing null explicitly blocks it. Give these actions distinct labels in the admin interface.

The unique index prevents duplicate keys within a scope. Laravel's upsert documentation explains the database-specific behavior and index requirements. This solves duplicate insertion, not every race in a larger read-modify-write workflow.

#Where a Small Custom Implementation Starts to Cost More

The native resolver is short because it deliberately omits the policies that a reusable settings system eventually needs:

  • Type stability. What prevents one request from writing integer 7500 and another from writing string "7500" for the same key? JSON preserves types, but does not enforce a consistent one across writes.
  • Validation across entry points. A controller may validate minimum_order_cents, while a queue job, import script, or Artisan command writes a negative value. The invariant belongs in a shared write boundary.
  • Override metadata. Admin screens need to show the effective value, whether it is inherited, and what happens when an override is removed.
  • Concurrency and cache invalidation. An upsert() guards a unique key, but caching must invalidate the affected scope after a committed write, including when the write happens inside an outer transaction.
  • Model identity and lifecycle. Manually constructed company:12 strings need consistent rules across models, morph aliases, deletion, and data migrations.
  • Tenant authorization. Knowing a scope identifier is not proof that the current user can edit it. Authorization must happen before either read or write.

Do not hide all of these behind a generic getSetting() helper and assume the complexity is gone. A tiny, single-model preference table might never need most of them. But once several teams are reimplementing the same resolver, validator, writer, and cache policy, a reusable component becomes worthwhile.

A useful rule is to keep the business schema explicit and the override engine generic. The engine should not decide who is authorized, what “minimum order” means, or whether a footer can be disabled.

#When Pharaonic Laravel Settings Fits

Pharaonic Laravel Settings packages the reusable parts: an Eloquent Setting model, global/named/model scopes, typed values, optional definitions, explicit fallback order, and per-scope caching. It does not discover a company's branches or authorize a request automatically.

The current 13.0.0 release targets Laravel 13 and PHP 8.3–8.5. Earlier Laravel major versions have their own release lines. In a compatible application:

1composer require pharaonic/laravel-settings
2php artisan migrate
3php artisan vendor:publish --tag=settings-config

The interesting part is not the number of helper methods. It is that an important key can have the same contract no matter which scope writes it. For the example above, define two different kinds of preferences in config/pharaonic/settings.php:

1// Entries inside the returned config array:
2'definitions' => [
3 'checkout.minimum_cents' => [
4 'type' => 'integer',
5 'nullable' => false,
6 'default' => 10000,
7 'rules' => ['min:0'],
8 ],
9 'receipt.footer' => [
10 'type' => 'string',
11 'nullable' => true,
12 'default' => 'Thank you for shopping',
13 ],
14],

This says that a checkout minimum is a non-negative integer and that the footer may deliberately be null. The default is used only when there is no stored value in the selected fallback chain; it is not a row copied to every model.

Add Pharaonic\Laravel\Settings\Concerns\HasSettings to your saved Company and Branch models. With $branch and $company already resolved and authorized, the same inheritance rule becomes:

1$minimumCents = settings($branch)
2 ->fallbackTo($company)
3 ->fallbackToGlobal()
4 ->getValue('checkout.minimum_cents');
5 
6$footer = settings($branch)
7 ->fallbackTo($company)
8 ->fallbackToGlobal()
9 ->getValue('receipt.footer');

The two distinct admin actions remain straightforward:

1$branch->setSetting('receipt.footer', null); // Explicitly suppress footer.
2$branch->forgetSetting('receipt.footer'); // Go back to inheriting.

To see why a branch displays a particular value, use the scoped builder's explainValue('receipt.footer') result, which includes the source scope and whether the value was inherited. To edit a stored value, setSetting() writes immediately; there is no separate model save() step.

A type is not merely a serialization hint. New keys infer their type from their first non-null value unless one is declared; later writes cannot silently change that stored type. The default casting policy is strict. The optional safe policy accepts only selected unambiguous conversions, rather than PHP's loose casts. Change a stored type intentionally with retypeValue() after reviewing the data.

These are reasons to adopt the package when the same invariants recur across models. They are not reasons to replace a perfectly clear nullable column with a settings row.

#Keep the Critical Boundaries in Your Application

Even with a settings package, three responsibilities remain yours.

Authorization: Route-model bind and authorize the actual branch before reading or writing preferences. An identifier such as company:12 is a locator, not a security boundary. For a form, use Laravel's policies and request validation before allowing changes.

1// Inside a controller action that has resolved $branch:
2Gate::authorize('update', $branch);
3 
4$validated = $request->validate([
5 'minimum_cents' => ['required', 'integer', 'min:0'],
6]);
7 
8$branch->setSetting('checkout.minimum_cents', (int) $validated['minimum_cents']);

The request validation defines the form's accepted input; the package definition protects the stored setting against writes from other parts of the application. Keep exact monetary amounts in integer minor units rather than floats.

Consistency: When several fields must change together, use a transaction on the relevant database connection. The package's setMany() stores values for one scope atomically and invalidates its scope cache after the outer transaction commits. If settings use a different database connection from the business records, a transaction on the default connection does not automatically cover both.

Operations: A model's settings are not an Eloquent relation. HasSettings::settings() returns a scoped builder, so with('settings') is not supported. Deleting a model does not purge its settings by default; force-delete purging is configurable, but bulk query deletes bypass model events. Shared cache storage matters when several servers serve the same tenants. Direct SQL writes bypass the package's type checks and cache invalidation.

Do not store API secrets as ordinary settings: the current release does not support encrypted definitions and rejects that option rather than writing purportedly encrypted values as plaintext.

#Test Intent, Not Just Returned Values

The important regression test is a state transition: inherit → override → suppress → inherit again. With Company and Branch factories, the HasSettings trait on both models, and the receipt.footer definition above, a Pest test can exercise it:

1<?php
2 
3use App\Models\Branch;
4use App\Models\Company;
5 
6it('distinguishes a missing footer from an explicitly hidden one', function () {
7 $company = Company::factory()->create();
8 $branch = Branch::factory()->for($company)->create();
9 
10 $company->setSetting('receipt.footer', 'Company footer');
11 
12 $read = fn () => settings($branch)
13 ->fallbackTo($company)
14 ->fallbackToGlobal()
15 ->getValue('receipt.footer');
16 
17 expect($read())->toBe('Company footer');
18 
19 $branch->setSetting('receipt.footer', 'Branch footer');
20 expect($read())->toBe('Branch footer');
21 
22 $branch->setSetting('receipt.footer', null);
23 expect($read())->toBeNull();
24 
25 $branch->forgetSetting('receipt.footer');
26 expect($read())->toBe('Company footer');
27});

This example assumes the app's factories and Branch::company() relation already exist. Add separate tests for an invalid checkout amount, writes of the wrong PHP type, unauthorized scopes, and rollback behavior. If you keep the native resolver instead, run the same behavioral test against it. The contract matters more than the implementation chosen.

#Don't Confuse Model Settings with Dynamic Laravel Configuration

There is an important boundary between these two use cases:

  • Model preferences: Code intentionally asks what this branch should do. Read through a settings resolver or scoped settings model. Do not change global config() state for another tenant's benefit.
  • Runtime framework configuration: An integration must continue using config('mail.from.address') or config('services.partner.timeout'), with database-backed overrides injected into Laravel's configuration repository. That is a different loading and lifecycle problem, covered in Laravel Dynamic Configuration Without Redeploys.

Conflating the two can cause cross-tenant behavior in queues or long-running workers. Pick a boundary based on how the consuming code reads the value, not on which package has the most helpers.

#Choose the Smallest Design That Preserves Intent

If you have two known preferences and no inheritance, ordinary model fields are probably the right solution. If you have a few tenant-specific settings, a JSON attribute or a small overrides table may be enough. When keys multiply across models, values need stable types, and several services depend on the same fallback rules, a dedicated settings model can eliminate duplicated infrastructure.

Whichever route you choose, keep the essential rule visible in code and in the admin interface: missing means inherit; stored null means an intentional value. That distinction prevents more real-world configuration bugs than any particular convenience method.