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

Zap Laravel Package

laraveljutsu/zap

Zap is a calendar and scheduling package for Laravel. Define availabilities, appointments, blocked times, and custom schedules for any resource like doctors, rooms, or employees. Handles overlaps, recurrence patterns, and availability queries. PHP 8.5+ and Laravel 13+.

View on GitHub
Deep Wiki
Context7

Getting Started

Install and publish in three commands. The package ships its own migrations and config, so the order matters:

composer require laraveljutsu/zap
php artisan vendor:publish --provider="Zap\ZapServiceProvider"
php artisan migrate

Before you run migrate, sanity-check the platform constraints: the README advertises PHP ≥8.5 / Laravel ≥13.0, while the CI workflow in the release notes tests PHP 8.2–8.5. If you're on Laravel 11 or 12, run composer why-not laraveljutsu/zap first — the install may silently resolve an older tag or fail outright.

Make a model schedulable. One trait, no interface:

use Zap\Models\Concerns\HasSchedules;

class Doctor extends Model
{
    use HasSchedules;
}

This gives you getBookableSlots(), getNextBookableSlot(), isBookableAt(), isBookableAtTime(), schedulesForDate(), schedulesForDateRange(), and the type scopes (appointmentSchedules(), availabilitySchedules(), blockedSchedules()).

Where to look first, in order:

  1. config/zap.php — specifically time_slots.buffer_minutes, default_rules.no_overlap, conflict_detection, validation. These defaults decide whether your first booking double-books.
  2. The core concept table: Availability (overlaps allowed), Appointment (exclusive), Blocked (exclusive), Custom (rules you define). Everything else is builder syntax on top of this.
  3. Zap\Facades\Zap vs. the zap() helper — pick one and stay consistent. The helper is easier to mock in tests; the facade reads better in service classes.

First use case — office hours + bookable slots. This is the 15-minute win that proves the package fits:

use Zap\Facades\Zap;

Zap::for($doctor)
    ->named('Office Hours')
    ->availability()
    ->forYear(2025)
    ->addPeriod('09:00', '12:00')
    ->addPeriod('14:00', '17:00')
    ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday'])
    ->save();

Zap::for($doctor)
    ->named('Lunch Break')
    ->blocked()
    ->forYear(2025)
    ->addPeriod('12:00', '13:00')
    ->weekly(['monday', 'tuesday', 'wednesday', 'thursday', 'friday'])
    ->save();

$slots = $doctor->getBookableSlots('2025-01-15', 60, 15); // 60min slots, 15min buffer

If you're on Laravel Boost 2.0, the package registers zap-schedules, zap-availability, and zap-recurrence skills automatically — have your agent read those before generating builder chains, since the API surface is large and easy to hallucinate.

Implementation Patterns

Learn the chain grammar once. Every schedule follows the same skeleton, and knowing it means you never need the README again:

Zap::for($model)          // subject
   ->named('...')         // label, required
   ->availability() | ->appointment() | ->blocked() | ->custom()
   ->forYear(2025) | ->from() ->to() | ->between() | ->on()
   ->weekly([...]) | ->monthly([...]) | ->firstWednesdayOfMonth() | ...
   ->addPeriod('09:00','17:00')  // repeatable
   ->withMetadata([...])
   ->noOverlap() | ->maxDuration(120) | ->noWeekends() | ->workingHoursOnly(...)
   ->save();

Only the type and the date range are strictly required; recurrence without a range defaults to something you should verify per release (the docs are not explicit), so always chain from()/to() or forYear() explicitly.

Separate "define the calendar" from "consume the calendar." In practice you write two distinct layers, and keeping them apart is what makes the package maintainable:

Write layer (admin UI, seeders, onboarding jobs, HR imports):

Zap::for($employee)->named('Regular Shift')->availability()
    ->weekDays(['monday','tuesday','wednesday','thursday','friday'], '09:00', '17:00')
    ->forYear(2025)->save();

Read layer (controllers, API resources, availability widgets):

$day = $request->date('date');
$slots = $doctor->getBookableSlots($day, (int) $request->slot, 15);
return SlotResource::collection($slots);

The write layer runs on a schedule (nightly rollover to next year, HR sync). The read layer must be cheap and indexable. Don't recompute schedules on every page load.

Booking flow: check, then re-check inside a transaction. isBookableAtTime() is a read; it does not lock. The safe pattern is:

DB::transaction(function () use ($doctor, $data) {
    $ok = $doctor->isBookableAtTime($data['date'], $data['start'], $data['end'], null, 30, 15);
    abort_unless($ok, 409, 'Slot no longer available');

    return Zap::for($doctor)
        ->named('Patient '.$data['patient_id'])
        ->appointment()
        ->from($data['date'])
        ->addPeriod($data['start'], $data['end'])
        ->withMetadata(['patient_id' => $data['patient_id'], 'type' => 'consultation'])
        ->noOverlap()
        ->save();
});

Note the signature drift: isBookableAtTime($date, $start, $end, $?, $slotMinutes, $bufferMinutes). The fourth parameter is undocumented in the README table — pass null and rely on the custom-slot and buffer args, which is what the examples do.

Model exceptions as blocked, not as deletions. Vacations, holidays, maintenance windows, equipment downtime — all the same construct:

Zap::for($employee)->named('Vacation Leave')->blocked()
    ->between('2025-06-01', '2025-06-15')
    ->addPeriod('00:00', '23:59')
    ->save();

This keeps the audit trail intact and means you never mutate the underlying availability row, so "remove the vacation" is one delete instead of a re-seed.

Compose cross-resource availability by intersecting. The package is single-subject per query. For "book the doctor and the room," build both slot lists and intersect on start time:

$doctorSlots = $doctor->getBookableSlots($date, 60, 15);
$roomSlots   = $room->getBookableSlots($date, 60, 15);

$both = collect($doctorSlots)->keyBy('start')
    ->intersectByKeys(collect($roomSlots)->keyBy('start'));

Wrap this in a BookingService rather than doing it in a controller — you'll want it in a command, an API, and a test.

Use custom() for anything the three primitives can't express. Recurring internal meetings, holds that expire, tentative bookings: custom() lets you attach your own rule methods and still get conflict detection, querying, and metadata for free.

Use metadata as your foreign key bridge. Rather than a polymorphic pivot table, stash the domain reference and query it later:

->withMetadata(['patient_id' => 1, 'type' => 'consultation', 'notes' => 'Follow-up', 'external_ref' => 'EHR-88213'])

Then ->appointmentSchedules()->get() plus a where('metadata->patient_id', $id) JSON filter. Fine at low volume; see the gotcha below at high volume.

Recurrence is the strongest part of the package — exploit it. The ordinal-weekday helpers (firstWednesdayOfMonth(), lastMondayOfMonth(), thirdTuesdayOfMonth()) and the odd/even-week pair (weeklyOdd() / weeklyEven()) cover most real staffing rotas without any custom logic:

// Alternating shift pattern across a whole year
Zap::for($nurse)->named('Week A')->availability()
    ->weeklyOdd(['monday','tuesday','wednesday','thursday','friday'])
    ->addPeriod('07:00','19:00')->forYear(2025)->save();

Zap::for($nurse)->named('Week B')->availability()
    ->weeklyEven(['monday','tuesday','wednesday','thursday','friday'])
    ->addPeriod('19:00','23:59')->forYear(2025)->save();

// Quarterly board room
Zap::for($room)->named('Board Meeting')->appointment()
    ->firstWednesdayOfMonth()->addPeriod('09:00','11:00')->forYear(2025)->save();

Seed your demo data with the builder. Because everything is fluent and save()-terminated, a seeder that builds ten resources' schedules reads as documentation — far better than raw inserts, and it exercises the same validation your UI does.

Wire conflict detection into a command for hygiene. Periodically scan for overlaps introduced by data imports or manual edits:

$schedule->appointmentSchedules()->get()->each(function ($s) {
    if (Zap::hasConflicts($s)) {
        Log::warning('Overlapping appointment', ['id' => $s->id, 'meta' => $s->metadata]);
    }
});

The noOverlap() rule prevents new overlaps at save time — it does not retroactively fix existing rows.

Gotchas and Tips

The advertised PHP/Laravel floor is aggressive and internally inconsistent. The badge and Requirements line say PHP ≥8.5, Laravel ≥13.0, while the v1.9.0 release notes say CI covers PHP 8.2–8.5. Expect Composer to behave differently across tags. Read the tag you actually install, and pin it ("laraveljutsu/zap": "1.9.*") rather than floating — this is a young package with a small release cadence and no dependents in the wild to absorb surprises.

Do the UUID work before php artisan migrate. With non-integer keys you must: extend Zap\Models\Schedule and Zap\Models\SchedulePeriod with HasUuids, point models.schedule / models.schedule_period at your subclasses, and edit the published migrations (id() → uuid('id')->primary(), morphs() → uuidMorphs(), foreignId('schedule_id') → foreignUuid('schedule_id')). Migrating first and fixing after means a rollback and a data wipe. This is the single most common onboarding failure with this package.

isAvailableAt() is deprecated — grep for it. If you're porting code from an older tutorial or AI-generated snippet, you'll get the deprecated call. Replace with isBookableAt() (boolean for the day) or isBookableAtTime() (boolean for a window). The semantics are also subtly different, so don't just rename — re-read the call site.

Buffer minutes mean different things at different call sites. config/zap.php has time_slots.buffer_minutes as a global default, but getBookableSlots($date, $slot, $buffer) and isBookableAtTime(..., $slot, $buffer) take per-call overrides. If your UI shows 15-minute gaps but your booking guard uses 30, you'll offer slots that then reject. Centralise the numbers in one config key or one service constant and pass it everywhere.

noOverlap() is opt-in per schedule, not a global invariant. default_rules.no_overlap in config influences the default, but the reliable path is to call ->noOverlap() explicitly on every appointment() you create. Treat findConflicts() / hasConflicts() as your safety net, not your primary control.

Vacation blocks with 00:00–23:59 will bite you. A midnight-to-midnight block is clean; 23:59 leaves a one-minute hole and, depending on how your slot generator rounds, can leave the first slot of the following day bookable or the last slot of the day unbounded. For multi-day blocks, add one explicit addPeriod('00:00','00:00') per boundary or use a custom rule. Test a block that starts or ends mid-week.

Timezone and DST are yours to handle. The package stores times, not zones. Pick a single convention (store UTC, render in the resource's zone, or store naive local and never cross zones), document it in the model, and pin it before you write slot generation. weekly(['sunday']) across a spring-forward boundary will produce a slot you can't book.

Watch query counts when listing many resources. schedulesForDate() / schedulesForDateRange() are per-model. Rendering a 50-doctor availability grid is 50 loads plus period hydration. Eager-load the relationship ($doctor->load('schedules.periods') if the trait exposes it) or build a single pre-aggregated slots table for read-heavy dashboards. v1.3.0 improved getAvailableSlots performance and fixed MySQL/Postgres time-overlap comparisons — if you're on an older tag and seeing wrong overlaps on Postgres, that's the fix you need.

Metadata is JSON; don't build your primary lookup on it. ->where('metadata->patient_id', 1) works but won't use an index on SQLite in the way you'd hope. If you genuinely need "all appointments for patient 1," add a real indexed column or a pivot — use metadata for display context, not for identity.

You build the booking UI and the HTTP layer. There is no controller, no route, no views, no cancellation/refund flow, no notification hooks. Plan for: a BookingService, a policy layer (who may book for whom), an idempotency key so double-submits don't create two appointments, and a cancellation path that deletes the appointment schedule (or converts it to blocked() if you want to preserve history).

Test with frozen time. Carbon::setTestNow() plus forYear() will make the suite start failing every January 1st if you hardcode years. Build a factory that derives the year from now()->year and test the rollover explicitly.

Extension points are the model classes and the config — that's it. There's no event system, no middleware, no macro registration documented. If you need custom period validation, subclass SchedulePeriod and register it in models.schedule_period, or wrap the builder in your own ScheduleBuilder. Don't fight the facade.

Given zero dependents and a single maintainer, budget for reading the source. The README is genuinely good and the AI-agent skills help, but the API surface is large and the documentation of edge behaviour (buffer semantics, recurrence defaults, conflict scope) is thinner than the method list suggests. Vendor the package or at minimum read src/ for the methods you lean on hardest — you'll be the one debugging them.

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.
adachsoft/changelog-linter
adachsoft/collection
payerurl/telegrambot-crypto-payment-instant-payout
dskripchenko/laravel-hybrid-search
riorizkyrainey/lara-ced
mehedimi/static-vel
zharp/zharp
nonsapiens/bigquery-model-sync
lgu-bayambang-ict-office/lgu-laravel-development-standards
andydefer/laravel-support
se7enxweb/flysystem
lampwick/laravel
jeslage/statamic-emojis
micahdshackelford/rmap-laravel
draftsmaninc/draftsman
square-bit/laravel-volition
himito/laravel-mailman
htinlynn/genius-helper
maco-studios/hephaestus
adachsoft/php-code-style