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

Has Parameters Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

  1. 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.
  2. The published migration — this is the actual schema, and it is the source of most production issues (indexes, uniqueness, column types).
  3. The Parameter model — where casting and the relationship live.
  4. $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.


Implementation Patterns

1. Wrap raw keys behind typed accessors

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.

2. Declare casts on the model

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',
    ];
}

3. Layered defaults: code → plan → record

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()
    );
}

4. Eager load on any list view

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.

5. Middleware and policies for gating

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.

6. Bulk-write through a single entry point

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.

7. Seeding and testing

// 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.

8. Admin surface

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.


Gotchas and Tips

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.

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.
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings
coleus/apps
seampact/laravel
emmyconceptng/laravel-ng-postcode
jjsquady/round-robin