petrknap/eloquent
A collection of enhancements and helper classes for Laravel Eloquent: UTC/local datetime casts, Optional wrappers that delegate missing-model handling, and repositories that provide a clean, expressive interface for persistence logic.
Install the package via Composer:
composer require petrknap/eloquent
There is no service provider or configuration to publish — the package is a grab-bag of classes you reference directly from your models. Your first stop should be src/Casts/, because that's the only subpackage with a worked example in the README:
PetrKnap\Eloquent\Casts\AsUtc — a cast that normalizes DateTimeInterface values to UTC on write, and returns Carbon in UTC on read.PetrKnap\Eloquent\Casts\AsPrivate — a cast class you reference by name for values you want handled as-private (likely opaque/hidden on serialization; verify against source).src/Optionals/ and src/Repositories/ exist but are only listed in the table of contents — read the source before relying on them.The canonical first use case is a timestamp problem: you want to persist UTC in the database but expose local wall-clock time to users without scattering Carbon::setTimezone() calls through your codebase. Start with a model like the README's:
use PetrKnap\Eloquent\Casts\AsUtc;
final class Event extends Model
{
protected function casts(): array
{
return [
'starts_at' => AsUtc::dateTime(),
'local_starts_at_utc' => AsUtc::dateTime(readonly: true),
'local_starts_at_tz' => AsPrivate::class,
];
}
protected function localStartsAt(): Attribute
{
return AsUtc::withTimezone(
'local_starts_at_utc',
$this->getDateFormat(),
'local_starts_at_tz',
);
}
}
Then verify with a round-trip: assign a timezone-aware Carbon, save(), refresh(), and print getTimezone()->getName() on both attributes. If both come back as UTC and Europe/Prague respectively, the package is behaving as advertised.
Pairing cast + accessor for timezone-preserving columns. AsUtc::withTimezone() is a factory returning an Attribute accessor/mutator pair. It reads two physical columns (_utc and _tz) and synthesizes a single virtual attribute (localStartsAt). This is the pattern to copy whenever you want "one logical field, two storage columns" without a custom cast: store the canonically-normalized value plus its context (timezone, unit, currency code, locale) and reassemble in an accessor.
Use readonly: true for columns written only by the model. The AsUtc::dateTime(readonly: true) variant signals that the physical column is derived — you don't set it directly, you set the virtual attribute and let withTimezone compute it. Treat any cast you mark readonly the same way you'd treat a computed column in SQL: never assign it in seeders or tests directly.
Prefer model-level casts() over $casts property. The package's examples use the Laravel 10+ method form. If your app is still on the $casts property, AsUtc::dateTime() still returns a string class reference or cast instance compatible with the array; just be aware that the readonly: keyword argument requires the method form to stay readable.
Repositories. If src/Repositories/ does what the name suggests, it's a base class for query objects. The idiomatic integration is to inject the repository into a service/action class rather than reaching for Model::query() inside controllers. Since documentation is absent, inspect the constructor and abstract methods before adopting it — if it's a final class with a hard-coded Model type, it may be cheaper to write a thin where-builder yourself than to fight it.
Optionals. "Optionals" in Eloquent context usually means nullable-typed attributes that distinguish "unset" from "null." If src/Optionals/ provides a value object (e.g. Optional::of($x) / Optional::empty()), wire it as a cast on the column and unwrap it in your domain layer — never in Blade or JSON resources, where you'd leak the wrapper.
Multi-cast fields. Combining AsUtc::withTimezone with AsPrivate (as in the example) is fine — each physical column gets exactly one cast; the virtual attribute gets the accessor. Don't stack two casts on one column.
composer.json for the PHP and Laravel version constraints, run composer why-not after install, and pin an exact version in composer.json rather than a caret constraint — there is no community pressure to keep BC.HEAD before trusting it.CastsAttributes implementation is often cheaper than the license review.withTimezone expects two columns to exist and to be typed correctly. The _utc column should be a datetime/timestamp; the _tz column must accept an IANA timezone identifier string (e.g. Europe/Prague) — a string(64) is the safe width; VARCHAR(32) will truncate America/Argentina/Buenos_Aires. If refresh() returns null for the accessor, check for null in either column before blaming the cast.getDateFormat() is referenced in the accessor closure. If you override it in your model, the package will use your format string when concatenating — which is what you want — but a mismatch between your format and the _utc column's storage format will silently produce wrong datetimes rather than throwing. Add a feature test that asserts $model->localStartsAt->toIso8601String() for a fixed input.$model->getCasts(), $model->getAttributeValue('field'), and $model->getAttributes() (raw, post-cast? — post-cast for getAttributeValue, raw DB values for getAttributes after refresh) are your tools. If casting looks inverted, DB::listen(fn($q) => dump($q->bindings)) to confirm what actually hit the database.final, and the cast classes likely are too. If you need to tweak behavior (e.g. a different default timezone), don't subclass — wrap. Compose AsUtc::dateTime() with your own Attribute factory, or copy the ~class into app/Casts/ if the license situation is murky.AsPrivate presumably controls toArray()/toJson() output. If your API is returning a raw _tz column that you expected to be hidden, that's where to look. Conversely, if your localStartsAt virtual attribute is missing from JSON, add it to $appends.2025-03-30 02:30 Europe/Prague (non-existent local time) and 2025-10-26 02:30 (ambiguous) will tell you quickly whether the cast is doing the right thing or just calling ->setTimezone('UTC').How can I help you explore Laravel packages today?