timacdonald/has-parameters
Laravel package for attaching flexible key-value parameters to Eloquent models, stored as JSON. A clean API lets you get, set, check, and remove parameters without extra tables or migrations.
Install and wire it up (three steps, ~10 minutes).
composer require timacdonald/has-parameters
php artisan vendor:publish --tag="has-parameters-migrations"
php artisan migrate
Publish the migration rather than relying on the vendor path long-term — you will want to edit it (see the unique-index gotcha below) and you do not want a composer update to silently change your schema.
Attach the trait to one model. Start with a model where configuration genuinely varies per record — a Team, Tenant, Workspace, or User — not with the model you think has the most settings.
use TimacDonald\HasParameters\HasParameters;
class Team extends Model
{
use HasParameters;
}
First use case: per-tenant switches you'd otherwise add a column for.
$team->setParameter('features.api_access', true);
$team->setParameter('billing.invoice_footer', 'Thanks for your business!');
$team->setParameter('limits.seats', 25);
$team->parameter('features.api_access'); // true
$team->parameter('limits.seats'); // 25
$team->parameter('branding.logo_url', '/default-logo.svg'); // default
$team->hasParameter('features.api_access'); // true
$team->removeParameter('billing.invoice_footer');
Where to look first when something surprises you.
vendor/timacdonald/has-parameters/src/Concerns/HasParameters.php — the entire public API lives here; it is short enough to read in one sitting, and reading it is faster than searching the docs for edge behaviour.Parameter model — where casting and the relationship live.$model->parameters (the eager-loadable relationship) — your debugging window into what is really stored.The mental model to adopt on day one: parameters are record-scoped data, not application configuration. If a value is config('services.x'), it belongs in config/. If it is "this tenant's API quota," it belongs here. Getting this distinction wrong is the single biggest source of misuse.
Never let string keys leak across the codebase. One small class per domain gives you autocomplete, a single place to change defaults, and a grep target when you need to delete a key.
final class TeamSettings
{
public function __construct(private Team $team) {}
public function apiAccessEnabled(): bool
{
return (bool) $this->team->parameter('features.api_access', false);
}
public function seatLimit(): int
{
return (int) $this->team->parameter('limits.seats', 5);
}
public function invoiceFooter(): string
{
return $this->team->parameter('billing.invoice_footer', '');
}
}
// Usage
$settings = new TeamSettings($team);
abort_unless($settings->apiAccessEnabled(), 403);
This is the difference between a maintainable parameters table and an undocumented shadow schema.
Casting happens on read, so declare it centrally instead of sprinkling (int) and (bool) at call sites.
class Team extends Model
{
use HasParameters;
protected $parameterCasts = [
'features.api_access' => 'boolean',
'limits.seats' => 'integer',
'billing.invoice_footer' => 'string',
];
}
A three-tier resolution keeps defaults out of the database and makes "reset to default" a removeParameter() call rather than a data fix.
public function seatLimit(): int
{
return (int) $this->team->parameter(
'limits.seats',
PlanDefaults::for($this->team->plan)->seatLimit()
);
}
parameter() reads from the loaded relationship. In an index page or a Blade loop, that is N+1.
$teams = Team::with('parameters')->paginate(50);
// Blade: no extra queries
@foreach ($teams as $team)
{{ $team->parameter('billing.invoice_footer') }}
@endforeach
Treat with('parameters') the same way you treat with('user') — if you are iterating, you should be eager loading.
This is the highest-leverage day-to-day pattern: resolve the model from route binding, read a parameter, decide.
class EnsureApiAccess
{
public function handle(Request $request, Closure $next)
{
$team = $request->route('team');
abort_unless($team->parameter('features.api_access', false), 403);
return $next($request);
}
}
Because route model binding resolves the Eloquent instance, this composes with Route::get('/teams/{team}/api', ...) with no extra plumbing, and the parameter never appears in the URL.
When an admin form submits ten fields, do not loop setParameter() blindly — wrap it so you can validate, skip unchanged values, and fire one event.
public function updateSettings(Team $team, array $input): void
{
$validated = validator($input, [
'limits.seats' => ['integer', 'min:1', 'max:500'],
'features.api_access' => ['boolean'],
'billing.invoice_footer' => ['nullable', 'string', 'max:255'],
])->validate();
foreach ($validated as $key => $value) {
$value === null
? $team->removeParameter($key)
: $team->setParameter($key, $value);
}
}
Pair this with a ParameterKey enum or constants class — its cases() becomes your validation rule list and your key registry and your admin UI field list. One source of truth, three consumers.
// Factory
public function configured(): static
{
return $this->afterCreating(function (Team $team) {
$team->setParameter('features.api_access', true);
});
}
// Test
$team = Team::factory()->configured()->create();
$this->assertTrue($team->fresh()->parameter('features.api_access'));
Always fresh() before asserting on a parameter read after a write in the same test — you want to prove the value round-tripped through the database, not that it is sitting in an in-memory relationship.
The package ships no UI. The cheapest viable one is a form generated from your key registry, rendering a text/checkbox/number input per key type, backed by the bulk-write method above. Budget hours, not days — but budget them, because without it every parameter change is a Tinker session.
Add a unique index yourself. The shipped migration does not guarantee one row per (model_type, model_id, key). Two rows for the same key means non-deterministic reads that look like a caching bug and will cost you an afternoon.
Schema::table('parameters', function (Blueprint $table) {
$table->unique(['parameterable_type', 'parameterable_id', 'key'], 'parameters_unique');
});
Watch the boolean cast on strings. Laravel's boolean cast is (bool) $value, and the string 'false' is truthy. Store 0/1 (or true/false through the setter, which serialises to 1/0), never the literal string "false" — especially if a seed file or an import wrote it.
Values are strings in the database. Casting is read-time only. There is no schema enforcing that limits.seats is numeric, so a stray "25 seats" will cast to 25 and silently truncate, or to 0 and silently disable a feature. Validate on write; never trust an unvalidated write path.
Do not filter or sort by parameter value. whereHas('parameters', fn ($q) => $q->where('key', 'x')->where('value', 'y')) is a join against an unindexed string column. It is fine at 10k rows and quietly painful at 10M. If a value is queried, filtered, sorted, or aggregated — promote it to a real column. Write this rule into your team docs alongside the key registry, or three engineers will independently rediscover the anti-pattern.
Orphaned rows after model deletion. Polymorphic relations have no database-level cascade. Add cleanup:
protected static function booted(): void
{
static::deleting(fn (Team $team) => $team->parameters()->delete());
}
Also sweep orphans left behind by past deletions before they inflate the table.
A typo'd key returns the default, silently. No exception, no log. This is the package's sharpest edge. Namespaced keys (billing.invoice_footer) plus a constants/enum registry plus a "key must exist in the registry" validation rule eliminates it.
Do not write through the relationship directly. $team->parameters()->create([...]) bypasses the setter's casting and de-duplication. Use setParameter() so the API stays the single write path and you keep the option to swap the storage layer later.
parameter() is not cached in the app layer. Each call goes through the loaded relationship; on an unloaded model that is a query. In a loop, or in a policy that runs per-item in a @can block, this compounds. Eager load, or memoise in your settings wrapper.
No encryption, no audit trail. Values are plaintext and there is no history of changes. Do not put secrets in parameters, and if compliance asks "who changed this tenant's limit and when," the answer is not in this package — you need your own write log around setParameter().
Pin the version and treat it as maintenance-mode. v1.5.3 is a dependency-constraint bump adding Laravel 13 support — no API change, no migration, safe to take. But it is a compat release, not a feature release: assume feature freeze, keep the constraint at ^1.5 so the next Laravel major doesn't pin you, and know that the source is small enough to vendor if it ever becomes load-bearing.
Debugging checklist when a value "isn't sticking": dump $model->parameters->pluck('value', 'key') (loaded state), then $model->parameters()->where('key', $k)->get() (raw DB), then check for duplicate rows and a stale in-memory relation. Those three checks resolve almost every "the parameter won't save" report.
How can I help you explore Laravel packages today?