Laravel Kvstore

TestsGitHub licenseMaintenanceGitHub release (latest by date)GitHub starsPackagist

A database-backed key-value store for Laravel, cached as a whole and with a per-entry cast


The whole store is cached as one single entry, so a cold read costs one query for every key at once, and no read after that costs a query at all.
Each entry carries its own cast, so a value always reads back as what it was written as.

An LLM-optimized documentation is included for your AI assistants.

Any contribution or feedback is highly welcomed, please feel free to create a pull request or submit a new issue.

Installation

The package requires PHP 8.2+ and Laravel 12 or 13.

Install it using composer:

composer require bgaze/laravel-kvstore

Publish the migration and run it:

php artisan vendor:publish --tag=kvstore-migrations
php artisan migrate

That is all the setup there is: the service provider and the KvStore alias are registered by
package discovery.

Publishing the configuration file is optional, and only needed to change the table, the cache store
or the cache entry name:

php artisan vendor:publish --tag=kvstore-config

--tag=kvstore publishes both the migration and the configuration file.

Quick start

The KvStore facade is ready as soon as the migration has run:

// Write a value, with the cast to apply to it:
KvStore::set('name', 'a string');
KvStore::set('retries', '3', 'integer');
KvStore::set('features', ['beta' => true], 'array');

// Read it back, cast:
$retries = KvStore::get('retries');            // int 3
$default = KvStore::get('missing', 'fallback');

// Absent key, or a stored null?
KvStore::has('name');

// Read the whole store in one query:
$all = KvStore::all();

// Remove one or several entries:
KvStore::remove('name');
KvStore::remove(['retries', 'features']);

The same methods are available on the Bgaze\KvStore\Client service, which you can inject rather
than reaching for the facade:

use Bgaze\KvStore\Client;

class Settings
{
    public function __construct(private readonly Client $store) {}

    public function retries(): int
    {
        return $this->store->get('retries', 3);
    }
}

The service is a singleton, and the kvstore.client container key is kept as an alias to it.

AI coding assistants

The repository ships a second documentation, written for an LLM rather than for you: dense, exhaustive, and pinned by the test suite instead of by prose.
Composer installs it along with the code, so it is already in your project, in the version you installed — nothing to download or paste.

Wire it in once. Add one line to the instructions file your tool reads at startup — CLAUDE.md, AGENTS.md, .cursor/rules/, .github/copilot-instructions.md, whichever yours is:

When using `bgaze/laravel-kvstore`, read
`vendor/bgaze/laravel-kvstore/docs/llm/index.md` first.

That is the whole setup, and it costs no network round trip since the guide is a local file.

What sits under vendor/bgaze/laravel-kvstore/:

  • docs/llm/index.md — the whole guide, in one file: public API, the three-state $type, the cast whitelist and what it refuses, the cache model and its invalidation rules, configuration, known limitations, upgrade notes. It closes on a table mapping every claim to the file that proves it.
  • llms.txt — the index of the above, in the llms.txt convention.

The payoff is the two mistakes the guide opens on, both of which cost a debugging session when an assistant guesses instead of reading.
The third argument of set() says what to do with the entry's cast, not what the value is — so an assistant that reads it as a type hint writes set($key, $value, 'string') and silently drops the cast the entry carried.
And the cache is not per key: any write drops the whole store, which is a deliberate trade-off an assistant should not try to optimise away.

A tool that browses rather than reads the disk takes the same files on GitHub: the usage guide and llms.txt.
Either way it is reference material, terse where this documentation explains: if you are reading rather than prompting, stay here.

Casts

The cast is stored on the entry itself, in the row's own type column, and applied when the value
is read. So a value always reads back as what it was written as, without the caller having to
remember anything.

The third argument of set is three-state

This is the part callers get wrong. $type does not describe the value being written; it describes
what to do with the cast stored on the entry.

set($key, $value, $type) Effect on the entry's cast
omitted, or null keep the cast the entry already carries
a cast name, e.g. 'integer' replace the cast with that one
false remove the cast
KvStore::set('retries', '3', 'integer');   // cast set
KvStore::set('retries', '5');              // still an integer
KvStore::set('retries', '5', 'string');    // now a string
KvStore::set('retries', '5', false);       // no cast at all

Anything else — an empty string, an unknown name — raises an InvalidArgumentException, and nothing
is written.

Supported casts

array, bool, boolean, collection, date, datetime, decimal, double, float,
immutable_date, immutable_datetime, int, integer, json, object, real, string,
timestamp.

date, datetime, decimal, immutable_date and immutable_datetime accept a parameter, as in
decimal:2 or datetime:Y-m-d H:i.

Encrypted casts are refused. all() casts every entry, so a single value you cannot decrypt —
after an APP_KEY rotation without APP_PREVIOUS_KEYS, typically — would make the whole store
unreadable rather than just its own key. Encrypt the value yourself before handing it over: the
ciphertext is then an opaque string to this package, and decrypting it stays where you control it,
on the one value you asked for.

A value has to be storable

The value column is text, so a value has to be a string by the time it gets there. A cast that
serialises it — array, json, object, collection — does that as the value is assigned. A
scalar cast does not, and neither does no cast at all.

set() refuses a value that would reach the column unserialised, and writes nothing:

KvStore::set('features', ['beta' => true]);            // InvalidArgumentException
KvStore::set('features', ['beta' => true], 'integer'); // same: casts nothing
KvStore::set('features', ['beta' => true], 'array');   // fine
KvStore::set('features', json_encode(['beta' => true]), false); // fine: already a string

Anything a text column takes as it is needs no cast: strings, numbers, booleans, null, a
DateTimeInterface, and any object with __toString — which includes a Collection, since it
renders as its own JSON. Everything else — an array, a stdClass, an enum — needs one.

Nulls, defaults and absent keys

A null written on a JSON-backed cast (array, json, object, collection) is stored as an
empty array, so such an entry always reads back as the collection type it declares rather than as a
null the caller has to guard. On any other cast a null stays null.

get() returns its default only when the key is absent. A stored null comes back as null,
whatever the default. Use has() to tell the two apart:

KvStore::set('nickname', null);

KvStore::get('nickname', 'anonymous');   // null — the key exists
KvStore::get('missing', 'anonymous');    // 'anonymous'
KvStore::has('nickname');                // true

Cache

The store is cached as one single entry holding every key, its raw value and its cast name:

['retries' => ['value' => '3', 'type' => 'integer'], /* ... */]

Nothing but strings and nulls goes into the cache — never a value already cast. That matters:
caching cast values would put Carbon, Collection and stdClass instances in there, and a cache
store told to restrict unserialization (cache.serializable_classes) could not rebuild them. Casts
are applied when the store is read.

A cold read costs one query for the whole store. The payload is then held in memory for the rest
of the request, so repeated reads cost neither a query nor even a cache round trip.

get() and has() cast only the key being read, and hold the result per key. all() casts the
whole store, which is what it is for.

This is not a per-key cache and must not be mistaken for one. The trade-off is deliberate: it suits
a store read on almost every request, and it means any write drops the cache for every key at once.

Invalidation

  • set() and remove() drop the cached store and the one held in memory.
  • Saving or deleting an entry through the Bgaze\KvStore\Entry model drops them too, so a seeder or
    a direct model write does not leave stale reads behind. Turn this off with
    kvstore.cache.auto_invalidate if you want to manage invalidation yourself.
  • A raw SQL write cannot be observed at all. Call KvStore::refresh() yourself after one.
  • flushResolved() drops only the store held in memory, leaving the shared cache alone. On Octane it
    is called at the start of every request, so a worker never serves one request the store it resolved
    for another. You rarely need it by hand.

A payload written by an earlier version of the package is rebuilt, not read, so upgrading needs no
cache flush.

An entry the framework cannot cast

The cast is validated on write, so set() cannot store a name the framework would refuse. Rows
written otherwise can: a direct SQL write, or a table filled by 1.x, which validated nothing.

Such an entry breaks only the key that carries it:

  • get() on that key raises InvalidCastException. Every other key reads fine.
  • has() casts nothing at all, so it answers for any key.
  • all() casts the whole store, so it raises.

That is the limit of reading the store as a whole — read the keys you need with get() if the table
may hold an entry you cannot fix yet.

Reading the store before its table exists

An application that reads a setting while booting could not start at all before the migration has
run — which would also break the very command that creates the table. So when the table is missing,
the store reads as empty and logs a warning, and that emptiness is not cached: the next read
tries again, so it heals by itself once the migration has run.

Any other database failure is raised as usual. The guard is narrow on purpose: hiding an unreachable
database would turn an outage into a silently empty store.

Configuration

Defaults are merged by the service provider, so they hold whether or not the file is published:

// config/kvstore.php
return [
    'table' => 'kvstore',
    'cache' => [
        'store' => null,            // null = the application default store
        'key' => 'kvstore',
        'auto_invalidate' => true,
    ],
];

The migration reads kvstore.table too, so renaming the table before running it is enough — there
is nothing else to edit.

The model

Bgaze\KvStore\Entry is a plain Eloquent model over the store table: string primary key key,
non-incrementing, no timestamps, no soft deletes. Writing through it is supported, and invalidates
the cache as described above.

Its getCasts() is overridden so the row's own type column drives the cast at runtime. This rides
on a framework method, which is why the test suite covers every supported cast end to end: a Laravel
change to that contract would otherwise break casting silently.

Reference

Every method is available on the KvStore facade and on the injectable Bgaze\KvStore\Client
service.

Method Signature Returns
set set(string $key, mixed $value, string|false|null $type = null): void
get get(string $key, mixed $default = null): mixed the cast value, or $default when the key is absent
has has(string $key): bool whether the key exists
remove remove(string|array $keys): void
all all(): Collection the whole store, keyed by key, values cast
refresh refresh(): void
flushResolved flushResolved(): void

Limitations

  • The whole store is one cache entry, so a write invalidates every key. This is the intended
    design, not an oversight.
  • value is a text column, so roughly 64 KB per entry. Change the column in the published
    migration if you need more.

Upgrading from v1

There is no migration to run. The migration is published, so your copy is already in place and
the schema has not changed.

Change What to do
Laravel 12 or 13 and PHP 8.2+ are now required 1.x resolved against any Laravel version, which is what this closes
Client methods are no longer static Use the KvStore facade, or inject Bgaze\KvStore\Client. The kvstore.client container key still works
The cast passed to set() is now validated A free-form cast name used to reach the framework and fail there; it is now refused up front, by name
encrypted:* casts are refused Encrypt the value yourself before storing it
set() refuses a value it cannot write An array or a plain object needs a cast that serialises it. Passing none used to store something unusable
Reading the store before migrating no longer throws Nothing. It reads as empty and logs a warning
Writes through the Entry model now invalidate the cache Nothing, unless you relied on the stale read. Turn it off with kvstore.cache.auto_invalidate
The default cache entry name is now kvstore Nothing. The old settings-store-cache entry is simply orphaned
The publish tag is now split --tag=kvstore-migrations, --tag=kvstore-config, or --tag=kvstore for both

A table filled by 1.x may hold a cast name the framework refuses, since 1.x validated none. Such an
entry breaks only its own key — see An entry the framework cannot cast.

Staying on 1.x? It remains installable as bgaze/laravel-kvstore:^1.1, and its documentation
lives on the v1 branch.

Moving between 2.x releases needs no work beyond the note on set() above; the
README states each of them.

Other packages

Feel free to visit my other packages:

bgaze/bootstrap-form

Bootstrap 4 & 5 forms builder for Laravel 12+

This package simplifies Bootstrap 4 & 5 forms creation in Laravel applications 12+
It renders Bootstrap 5 markup by default and fully supports Bootstrap 4 for backward compatibility.
Model form binding and automatic error display are supported, as well as most Bootstrap form features: form layouts, custom fields, input groups, and more.

Github Documentation

SnapStack

100% local browser captures for your AI assistant

SnapStack is a browser extension that captures any tab in one click and stacks it locally, so your AI assistant can read the screenshots on demand over MCP.
Nothing is ever uploaded: captures go only to a small server on your own machine. No account, no telemetry.
Works with any MCP-capable client (Claude Code and others), on Chrome, Edge and Firefox.

Github Documentation

@bgaze/color-palette

A grid colour picker in the spirit of Google Docs

Vanilla TypeScript, with a single runtime dependency for the positioning.
It works standalone, ships optional Bootstrap 4 and Bootstrap 5 themes, and is accessible on purpose.

Github Documentation