Larakeep

Larakeep moves the logic that fills derived model fields (an excerpt, a reading estimate, a denormalized search column, a cached count) out of observers and into small classes called Keepers.
If you have used Laractions, the distinction is one line: an action performs an operation, a keeper fills a field.
Installation
composer require edulazaro/larakeep
The service provider auto-registers and there is nothing to publish. Keepers are plain classes; the only wiring is a trait and an attribute.
Writing a keeper
A keeper receives the model in its constructor and exposes one method per field it computes. The convention is get followed by the PascalCase of the field, so excerpt maps to getExcerpt() and reading_minutes to getReadingMinutes().
php artisan make:keeper ArticleKeeper
namespace App\Keepers; use App\Models\Article; use Illuminate\Support\Str; class ArticleKeeper { public function __construct(private Article $article) {} public function getExcerpt(): string { return Str::limit(strip_tags($this->article->body), 160); } public function getReadingMinutes(): int { return max(1, (int) ceil(str_word_count(strip_tags($this->article->body)) / 200)); } }
Each method returns the value; it does not assign it. Nothing in a keeper touches the database.
Binding it to the model
Add HasKeepers and bind the keeper with the #[KeptBy] attribute, which is repeatable so a model can carry several.
use EduLazaro\Larakeep\Concerns\HasKeepers; use EduLazaro\Larakeep\Attributes\KeptBy; use App\Keepers\ArticleKeeper; #[KeptBy(ArticleKeeper::class)] class Article extends Model { use HasKeepers; }
If you would rather not annotate the model, register the same binding in a service provider's boot() with Article::keep(ArticleKeeper::class).
Running it
process() runs the matching method and assigns its return value to the model attribute. Pass an array to fill several at once.
$article->process('excerpt'); $article->process(['excerpt', 'reading_minutes']);
process() sets the attribute in memory and returns the model, but does not persist. Save as usual, chaining if you like:
$article->process(['excerpt', 'reading_minutes'])->save();
Inside a saving observer there is no second save, because the attribute is set before the write:
class ArticleObserver { public function saving(Article $article): void { $article->process('excerpt'); } }
Backfilling existing rows
The same keeper replays over a whole table when the formula changes:
Article::query()->chunkById(500, function ($articles) { foreach ($articles as $article) { $article->process(['excerpt'])->save(); } });
Fields that take arguments
Suffix the method with With and pass the arguments as an array to processWith().
public function getExcerptWith(int $length): string { return Str::limit(strip_tags($this->article->body), $length); } $article->processWith('excerpt', [280]);
Other verbs
get is only the default prefix. Name a method configureExcerpt() and run it through processTask('configure', 'excerpt'), or processTaskWith() when it takes arguments. Useful when one keeper computes a field in more than one way.
The general form
The four verbs are one line each over the same method, and that method is public, so you can call it directly:
$article->processMaintenanceTask('get', 'excerpt'); // process('excerpt') $article->processMaintenanceTask('configure', 'excerpt'); // processTask('configure', 'excerpt') $article->processMaintenanceTask('get', 'excerpt', [280]); // processWith('excerpt', [280])
It is worth knowing because it explains the naming rule rather than asking you to remember four of them. The method looked for is the task, then the field in PascalCase, then With if arguments were passed: get plus reading_minutes is getReadingMinutes(), and the same call with arguments looks for getReadingMinutesWith().
Two behaviours fall out of that, and both are what make several keepers on one model work:
- A keeper that does not implement the method is skipped, quietly and by design. That is how one model can carry a keeper for its text fields and another for its counters without either knowing about the other.
- A field no keeper claims is left untouched. Nothing is set to null, and nothing throws. A typo in a field name is therefore silent, which is the price of the same rule.
Every form returns the model, so they chain into a save().
Reach for the verbs in application code, since process('excerpt') reads better than its general form. Reach for processMaintenanceTask() when the task is a variable, which is the case in a backfill command that takes the verb as an argument.
Keepers or actions
Both put logic in a class of its own, so the question comes up. The line is not about size or about how many models are involved, it is about what is left behind when the code has run.
A keeper fills a field. An action does something. After a keeper runs, one attribute holds a derived value and nothing else in the world has changed. After an action runs, an invoice exists, an email is gone, a portal has been told.
That difference has a practical edge you can test against: a keeper is safe to replay and an action usually is not. Running process(['excerpt']) over the whole table is a backfill, and running it twice is harmless because the value is a function of the row. Running SendInvoice twice is a second invoice in somebody's inbox. If re-running the code over ten thousand rows would frighten you, it is not a keeper.
Three more that follow from it:
- A keeper returns, it does not write. It never saves, never dispatches, never sends. Larakeep takes the return value and assigns it, and persisting is the caller's business. An action owns its operation end to end, saving included.
- A keeper belongs to the record, an action belongs to the verb.
ArticleKeeperis a property of what an article is.PublishArticleis a thing you do to one. - A keeper does not go on a queue. It computes a value at save time, and the value is needed now. An action can be dispatched by changing one word, and that is most of the reason to have one.
They compose in one direction. An action can call process() before saving the model it just built, and that is normal. A keeper calling an action is the sign the logic was in the wrong place: it is doing something rather than computing something.
The full action side of this is in the Laractions documentation.
built and maintained by Edu Lazaro · MIT license