michielfb/laravel-nova-time-field
A Laravel Nova field that adds a time picker input for selecting times in Nova resources, with simple formatting and seamless integration into Nova forms and indexes.
Confirm Nova credentials before anything else. This package requires laravel/nova, which resolves from a private repository. On a fresh machine or CI runner, composer require will fail with an auth error that looks like a package problem but is not. Get credentials into ~/.composer/auth.json (or the project's composer.json repositories block) first.
composer require michielfb/laravel-nova-time-field
No migration, no publish step, no config file. The service provider is auto-discovered. Pin the version explicitly — do not float on ^1.0. Read the laravel/nova constraint inside vendor/michielfb/laravel-nova-time-field/composer.json and confirm it names the major you actually run before you write a single field definition. Nova's field API has changed shape across 3 → 4 → 5, and a December 2024 release date is not evidence of compatibility with your install.
Read the source in one sitting. It is small, and doing this first saves an afternoon of guessing later:
ls vendor/michielfb/laravel-nova-time-field
cat vendor/michielfb/laravel-nova-time-field/composer.json # laravel/nova constraint
ls vendor/michielfb/laravel-nova-time-field/dist/js # are compiled assets committed?
cat vendor/michielfb/laravel-nova-time-field/src/*.php # the Field subclass
Three things to note on that pass: the Nova constraint, whether dist/js/field.js exists (if not, you inherit an npm build step for a twenty-star dependency), and what the field's resolve/fill methods do with empty input — that last one drives the empty-string-vs-null problem described below.
First use case: the lowest-risk resource with a genuine time-of-day column. A Location with opens_at or a Shift with starts_at is ideal. Confirm the exact namespace from vendor/ before copying anything:
use Michielfb\NovaTimeField\Time;
public function fields(NovaRequest $request): array
{
return [
Time::make('Opens At')
->rules('required', 'date_format:H:i'),
Time::make('Closes At')
->rules('nullable', 'date_format:H:i')
->nullable(),
];
}
Then verify against the database, not the browser. Submit 00:00, 23:59, and a cleared field, and read the raw column back with DB::table(). If the stored representation does not match what your schema expects, stop — that is the only decision that matters, and everything downstream depends on it.
Wrap it in an app-local field class. This is the highest-leverage move with a package this thin. Subclass once, set your conventions there, and keep resources declarative:
namespace App\Nova\Fields;
use Michielfb\NovaTimeField\Time as BaseTime;
class TimeOfDay extends BaseTime
{
public function __construct($name, $attribute = null, $resolveCallback = null)
{
parent::__construct($name, $attribute, $resolveCallback);
$this->rules('nullable', 'date_format:H:i');
$this->help('24-hour format, e.g. 09:30');
}
}
Now the day this needs to become a fork — a Nova major bump, seconds support, a step attribute — you change one file instead of every resource. Swapping to a local implementation is a namespace edit, which is exactly the optionality you want from a low-adoption dependency.
Handle range validation outside the field. The field validates one wall-clock value. Pair correctness is your job, in a FormRequest or NovaRequest:
public function rules(): array
{
return [
'opens_at' => ['required', 'date_format:H:i'],
'closes_at' => ['required', 'date_format:H:i', 'after:opens_at'],
];
}
Be careful with after: on bare times — it does not understand cross-midnight windows and will reject a legitimate 22:00–02:00 range. For overnight spans, compare minutes-since-midnight explicitly in a custom rule or a withValidator hook rather than leaning on the built-in.
Normalize on write, in one place. Whatever the browser emits, converge on a single storage shape in the model. A mutator keeps the rest of the app honest regardless of what came in:
protected function opensAt(): Attribute
{
return Attribute::make(
set: fn ($value) => blank($value) ? null : substr($value, 0, 5).':00',
);
}
This doubles as your defense against the empty-string failure mode below, and it means a future field swap does not silently change what lands in the column.
Migrating from a DateTime field: two deploys, not one. Existing rows carry a spurious date. First release: change the UI and write HH:MM into the existing column while reading it back through a format accessor; verify against real rows. Second release: alter the column to native time. Combining "change the widget" with "change the storage type" in a single release means a broken round-trip gives you no signal about which half broke it.
Gate index/detail rendering explicitly. Confirm the field produces a sensible read-only value in index() and detail() rather than a raw input. If it only behaves cleanly in the form context:
TimeOfDay::make('Opens At')->onlyOnForms(),
Text::make('Opens At')->onlyOnIndex(),
Standardize across resources. The day-to-day value here is consistency: locations, staff availability, and service windows all rendering the same control with the same format hint. The moment someone reaches for Text with a regex "just this once," that is exactly the drift the package exists to prevent — use the wrapper.
Silent seconds truncation. HTML5 time inputs default to a 60-second step, so a legacy TIME column holding 09:30:15 returns as 09:30:00 after one edit-and-save. No error, no log line. Migrate seconds away deliberately, or set the step attribute if the package exposes one — verify in the compiled Vue rather than assuming.
Empty input vs NULL. Browsers submit "" for a cleared time input. Strict MySQL rejects "" for a time column with an incorrect-value error instead of storing NULL. Test the cleared-field case on day one and normalize in a mutator or a model saving hook.
12/24-hour rendering is browser locale, not yours. Chrome on a US locale renders a 12-hour control with AM/PM while your column expects 24-hour input; Safari has behaved differently again. Operators book the wrong slot and it shows up in business data, never in logs. Mitigate with ->help() stating the expected format, smoke-test on the browser matrix your operators actually use, and keep server-side date_format:H:i non-negotiable.
The field is not timezone-aware, by design. 09:00 is a wall-clock time for a location or a shift, not an instant. Do not use it for anything requiring conversion, DST handling, or cross-region comparison — that is a datetime plus a timezone column and a different field entirely.
Missing or stale compiled assets. If dist/js/field.js is not committed, you inherit an npm build step for a tiny dependency — a poor trade against writing the component yourself. If it is committed, a Nova major upgrade can still invalidate it, leaving the input blank in production with no PHP error. Symptom: the form renders but the input is missing or unstyled. Check the console for a failed asset load and compare mix-manifest.json entries.
Debugging checklist, in order. (1) Is the field registered at all — does the label render? (2) Is the asset served — any 404s in the network tab? (3) What is the actual form payload in the request? (4) What is in the database column? Each step isolates a different layer; jumping to the Vue side first wastes time.
Extension points are the source, not an API. Single maintainer, zero dependents. Do not file an upstream issue and wait. Fork into app/Nova/Fields or a private Composer package — it is hours, not weeks — and keep your wrapper class as the seam so the fork is a one-line swap.
Log it with an owner. Add the dependency to your inventory with a named maintainer and an exit plan. Runtime cost is nil and integration is a day; the stewardship cost is real. You are adopting code, not buying support, and the next Nova major is when that distinction becomes visible.
How can I help you explore Laravel packages today?