NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages

Off until you switch it on:

EVENTS_ROUTES=on
  • Prefix events.routes.prefix (api), route names events.*, middleware events.routes.middleware (['api', 'throttle:events-api']); write routes add events.routes.auth_middleware (['auth']).
  • A switch that is not set stays off — including a published config whose routes array lacks the key, since the merge is shallow.
  • Publish --tag=events-routes to edit the route file: once routes/events.php exists in your app it loads instead of the package’s, and the switch still gates it.
  • Writes always need a signed-in user. After your auth_middleware every write route runs the package’s own check: no signed-in user, or a guard user that is not an Eloquent model, is a 401 — even with auth_middleware = []. It runs before validation, so a guest never learns the shape of a request. Read routes and the invitation-link routes still allow guests.

Conventions

  • {event} is the uuid — constrained, so /events/feed.ics never reaches it — and an event the caller may not see is a 404 exactly like a missing one, on every endpoint. {rsvp} and {tier} are scoped to the event (a ticket type by id or key); {venue} is numeric.
  • Your models are referenced by alias, never by class name: {type: user, id: 42} in bodies and user:42 in paths and filters (events.organizers, events.attendees). In a body every failure — an unknown alias, a malformed key, a missing row — is the same 422 unknown_alias.
  • Times in requests are wall clock in the event’s timezone or an ISO-8601 instant with an offset; responses use ISO-8601 with the event’s own offset.
  • Text fields take a string (the current locale) or a locale map. Prices are decimal strings with an optional currency; responses render money-for-laravel’s MoneyResource — {minor, decimal, currency, exponent, formatted}, with minor a string and formatted for display only. A free ticket type renders price: null.
  • Lists are Laravel length-aware paginators (data, links, current_page, per_page, total, …), with per_page capped at events.routes.max_per_page.

Routes

All 49 routes, grouped, with paths relative to the prefix. Rows marked “Guests too” also answer guests; every other route needs a signed-in user:

Method · pathNameAbilityNotes
GET /eventsevents.indexviewAnyGuests too. Discovery filters below → paginated EventResource.
GET /events/feed.icsevents.feed—Guests too. ?category=&label=&organizer=alias:key&venue= → text/calendar (inline); throttle events-feeds.
GET /events/{event}events.showviewGuests too. {data: EventResource} with hosts and viewer.can.
GET /events/{event}/calendar.icsevents.calendarviewGuests too. text/calendar (attachment); the join link only for a viewer who may see it.
GET /events/{event}/tiersevents.tiers.indexviewGuests too. Hidden ones for managers only.
GET /events/{event}/attendeesevents.attendees.indexviewAttendeesGuests too. ?status=&checked_in=&tier=&per_page= (default 25) — managers only for the filters.
GET /events/{event}/hostsevents.hosts.indexviewGuests too. {data: [host reference]}.
GET /event-venuesevents.venues.index—Guests too. ?mine=1&near=lat,lng&radius=&per_page= (default 25).
GET /event-venues/{venue}events.venues.show—Guests too.
POST /event-invitations/accept · declineevents.invitations.accept · declinethe tokenGuests too. {token, response?} (going default, interested) → RsvpResource (owner view) / {status: declined}; throttle events-rsvp.
POST /eventsevents.storecreate201 {data: EventResource}.
PATCH /events/{event} · DELETE /events/{event}events.update · destroyupdate · deleteOnly the fields sent change; ?notify=0 writes silently · 204.
POST /events/{event}/restoreevents.restorerestoreTrashed events, managers only (others 404).
POST /events/{event}/publish · unpublish · completeevents.publish · …publish · …{data: EventResource}
POST /events/{event}/postpone · cancelevents.postpone · cancelpostpone · cancel{reason?} (≤ 500).
POST /events/{event}/rescheduleevents.reschedulereschedule{starts_at, ends_at?, reason?}
POST /events/{event}/move-onlineevents.move-onlinemoveOnline{url, keep_venue?}
PUT · DELETE /events/{event}/posterevents.poster.update · destroyupdateMultipart poster or a draft token · removes it.
POST /events/{event}/tiers · tiers/reorderevents.tiers.store · reordermanageTiers201 AdmissionTierResource · {order: [tier id, …]} → 204.
PATCH · DELETE /events/{event}/tiers/{tier}events.tiers.update · destroymanageTiersA tier by id or key; only the fields sent · 204.
GET /events/{event}/rsvp · rsvp/qr.svgevents.rsvp.show · qr—Your own RSVP (owner view) or null · your code as image/svg+xml, Cache-Control: no-store, private.
PUT · DELETE /events/{event}/rsvpevents.rsvp.update · destroyrsvp{response, guests?, tier?, note?} · answers not_going; throttle events-rsvp.
POST /events/{event}/attendeesevents.attendees.storemanageAttendees{attendee: {type, id}, tier?, guests?, over_capacity?} → 201
PATCH · DELETE /events/{event}/attendees/{rsvp}events.attendees.update · destroymanageAttendees{tier?, guests?} — both or neither applies · 204.
POST · DELETE /events/{event}/attendees/{rsvp}/check-inevents.attendees.check-in · undo-check-incheckInManual check-in {guests?} (no code) · undo.
POST /events/{event}/check-insevents.check-ins.storecheckInA door scan {code, guests?} → CheckInResultResource, 201 first, 200 repeat; throttle events-check-in.
GET /events/{event}/waitlist · POST …/waitlist/promoteevents.waitlist.index · promoteviewWaitlist · manageAttendeesIn promotion order · {limit?} (1–1000).
POST · DELETE /events/{event}/hosts…events.hosts.store · destroymanageHosts{person: {type, id}} (an Organizer) → 201 · {host} = alias:key → 204.
GET · POST · DELETE /events/{event}/invitations…events.invitations.index · store · destroyinviteTokens never rendered · {person: {type, id}, message?} (≤ 1000) → 201 · {invitee} = alias:key → 204.
POST · PATCH · DELETE /event-venues…events.venues.store · update · destroyVenuePolicyVenue body below → 201 · only the fields sent · 204.
POST /event-venues/{venue}/geocodeevents.venues.geocodeVenuePolicy geocodeAlways re-geocodes (force).

There is no payment-confirmation route: confirm from your provider’s webhook in PHP with Events::rsvps()->confirmPayment($reference).

Creating an event

{
  "title": { "en": "Jazz Night", "sk": "Jazzový večer" },
  "venue_id": 7,
  "starts_at": "2026-11-14 20:00",
  "duration": "PT3H",
  "capacity": 120,
  "max_guests": 1,
  "category": "jazz",
  "labels": ["Live", "Late night"],
  "tiers": [
    { "name": "Free entry", "key": "free" },
    { "name": "Supporter", "price": "15.00", "currency": "EUR", "capacity": 30 }
  ],
  "publish": true
}
FieldRules
titleRequired; a string or a locale map, ≤ 200 characters each.
description≤ 20,000.
organizer{type, id} of events.organizers — default the caller; must allow the caller.
venue_id · venueAn existing venue (any organizer may hold an event at any venue) — or an inline new venue owned by the caller: name (required), street, city, region, postal_code, country, coordinates, timezone.
online_url · attendance_mode · visibility · guest_list · timezone≤ 2048 · enum values · an IANA name.
starts_at · ends_at · duration · doors_open_at · all_day_from · all_day_throughstarts_at required unless all_day_from; all-day dates Y-m-d.
capacity · max_guests0–10,000,000 · 0–1000
waitlist · waitlist_auto_promote · show_counts · publishBooleans; publish: true announces it in the same transaction.
rsvp_opens_at · rsvp_closes_at · remindersStrings; ≤ 5 reminders.
category · labelsA category of the tree · ≤ 10 strings (in the organizer’s catalogue when events.tags.scope = organizer).
tiers≤ 50 of {name, key?, price?, currency?, capacity?, hidden?}; price as ^\d{1,12}(\.\d{1,4})?$.
poster_draftA media-library draft token.

PATCH /events/{event} changes only the fields sent, and null clears a nullable one; category (null clears) and labels (exactly these) apply in the same transaction. A new start on an announced event is refused — use POST /reschedule. ?notify=0, or notify: false in the body, writes silently.

Ticket types and venues

Ticket type bodies take name (required on create, ≤ 200), description (≤ 2000), key (≤ 64; derived from the name when omitted), price + currency, capacity, sale_starts_at, sale_ends_at, min_party (1–1000), max_party, hidden and position. Venue bodies take name (required on create), street, city, region, postal_code, country (2 letters), formatted_address, coordinates (lat,lng, [lat, lng] or {latitude, longitude}), timezone, capacity and geocode, and on create owner ({type, id}, who must allow the caller) or shared (admins and abilities.shared_venues only). Without owner or shared, a new venue is the caller’s own. On PATCH each address field changes on its own, and ownership never changes.

Your RSVP

PUT /api/events/{event}/rsvp with:

{ "response": "going", "tier": "free", "guests": 1, "note": "Wheelchair access" }

returns your own RSVP in the owner view, with your check-in code and the QR URL:

{
  "data": {
    "id": 42,
    "status": "going",
    "guests": 1,
    "party_size": 2,
    "tier": { "id": 1, "key": "free", "name": "Free entry" },
    "note": "Wheelchair access",
    "waitlist_position": null,
    "held_until": null,
    "amount": null,
    "checked_in_at": null,
    "checked_in_guests": null,
    "responded_at": "2026-11-02T18:12:40+01:00",
    "payment_url": null,
    "check_in_code": "EV1-…",
    "qr_url": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21/rsvp/qr.svg"
  }
}

The guest list

GET /events/{event}/attendees lists any status for managers (?status=, default going) with the check-in record — never codes — filtered by checked_in and tier. Everyone else who may view the list sees going attendees only, as {attendee, guests}: status=interested is a 403 for them, and status, checked_in and tier are ignored — the whole list, never one narrowed to who is at the venue or who holds a hidden ticket type.

Discovery: GET /events

Built on the model’s scopes, so PHP and HTTP filter identically; query-builder splits every value on commas. The base is browse($viewer) — visible, listed, announced:

ParameterScope
filter[when]=upcoming|ongoing|pastupcoming() …
filter[from], filter[to]Overlapping the range, wall clock in ?tz= (default app.timezone).
filter[date]=2026-11-14onLocalDate() — a real calendar date (2027-02-30 is a 422).
filter[near]=48.14,17.11,25near() — radius optional, ≤ geo.max_radius_km.
filter[category]=jazzinCategory() with sub-categories.
filter[labels]=live,outdoorEvery label.
filter[organizer]=user:42organizedBy() — the row is never looked up; a missing one matches nothing.
filter[venue]=7,8 · filter[mode]=online,mixedatVenue() · withAttendanceMode()
filter[free]=true · filter[available]=truefree() · withSeatsAvailable()
filter[search]=…search() — escaped contains-search on title and description (commas kept).
filter[status]=postponed,rescheduledinPhase()
filter[mine]=organizing|attending|interestedYour own events, every visibility (organizing includes drafts); guests get none.
sort=starts_at (default), -starts_at, created_at, -created_at, popularity, distancedistance needs filter[near] (400 otherwise); popularity ranks an event with show_counts off as zero going — except under filter[mine]=organizing.
per_pageDefault 15, ≤ events.routes.max_per_page.

An invalid filter value is a 422 invalid_query. A page loads venues and owners, organizers, tags, posters, ticket types, the viewer’s own RSVP, the category paths and which events the viewer manages in a fixed number of queries — the count does not grow with the page.

Resources

A free event, as a guest sees it in the list:

{
  "id": "0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21",
  "slug": "jazz-night",
  "title": "Jazz Night",
  "description": "A live quartet, then an open jam session.",
  "phase": "scheduled",
  "schema_status": "EventScheduled",
  "attendance_mode": "offline",
  "starts_at": "2026-11-14T20:00:00+01:00",
  "ends_at": "2026-11-14T23:00:00+01:00",
  "doors_open_at": null,
  "previous_starts_at": null,
  "all_day": false,
  "timezone": "Europe/Bratislava",
  "starts_on": "2026-11-14",
  "ends_on": "2026-11-14",
  "venue": {
    "id": 7,
    "name": "Blue Note",
    "slug": "blue-note",
    "address": {
      "street": "Hlavná 1", "city": "Bratislava", "region": null, "postal_code": "811 01",
      "country": "SK", "formatted": "Hlavná 1, 811 01 Bratislava, SK"
    },
    "coordinates": { "latitude": 48.1439, "longitude": 17.1097 },
    "timezone": "Europe/Bratislava",
    "capacity": null,
    "owner": null,
    "distance_km": null,
    "deleted": false
  },
  "online_url": null,
  "organizer": { "id": 1, "type": "User" },
  "category": { "id": 3, "slug": "jazz", "name": "Jazz", "color": null, "icon": null, "path": ["Music"] },
  "labels": [{ "id": 4, "slug": "live", "name": "Live", "color": null, "icon": null }],
  "poster": null,
  "tiers": [
    {
      "id": 1, "key": "free", "name": "Free entry", "description": null, "price": null, "free": true,
      "capacity": null, "seats_left": null, "on_sale": true, "sale_starts_at": null, "sale_ends_at": null,
      "min_party": 1, "max_party": null, "position": 1
    }
  ],
  "capacity": 120,
  "seats_left": 85,
  "counts": { "going": 35, "waitlisted": 0 },
  "viewer": { "rsvp": null },
  "links": {
    "self": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21",
    "ics": "https://example.com/api/events/0195f2c4-7a1e-7c3b-9d2e-5b8a1f4c6e21/calendar.ics",
    "url": "https://example.com/en/events/jazz-night"
  }
}
ResourceShape
EventResourceSee the example above. status and visibility for managers only; online_url for managers and going attendees; seats_left and counts for managers, or everyone with show_counts (counts.interested for managers); hosts and viewer.can on the detail view only.
VenueResourceid, name, slug, address {street, city, region, postal_code, country, formatted}, coordinates {latitude, longitude} | null, timezone, capacity, owner, distance_km (3 decimals, when selected), deleted — never meta
AdmissionTierResourceid, key, name, description, price (MoneyResource or null), free, capacity, seats_left, on_sale, sale_starts_at, sale_ends_at, min_party, max_party, position, and hidden for managers.
RsvpResourceid, status, guests, party_size, tier {id, key, name}, note, waitlist_position, held_until, amount, checked_in_at, checked_in_guests, responded_at; the owner view adds payment_url (while held), check_in_code and qr_url; the managers’ view adds attendee, check_in_method and checked_in_by — never the code.
InvitationResourceid, status, invitee, invited_by, message, expires_at (null while postponed), responded_at, sent_at — never the token.
CheckInResultResourcefirst_check_in, guests, checked_in_at, method, attendee, rsvp (RsvpResource)

Reference resources

Every person or team a response mentions renders through its own resource — by default {id, type}, type being the model’s class basename (User), never a class name or a morph type, even without a morph map. A missing model renders null:

Config keyDefaultRenders
events.resources.organizerEventOrganizerResourceorganizer
events.resources.hostEventHostResourceco-hosts
events.resources.attendeeEventAttendeeResourceattendees
events.resources.inviteeEventInviteeResourceinvitees
events.resources.actorEventActorResourceinvited_by, checked_in_by
events.resources.venue_ownerVenueOwnerResourcea venue’s owner

Point a key at your own JsonResource — it receives the model — to add a name or an avatar. Blank means the default; anything that is not an instantiable JsonResource subclass throws, naming the key. Lists eager-load the related models, so a resource that reads only their attributes adds no queries:

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

final class PersonResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return ['id' => $this->resource->getKey(), 'name' => $this->resource->name, 'avatar' => $this->resource->avatar_url];
    }
}

// config/events.php
'resources' => [
    'organizer' => App\Http\Resources\PersonResource::class,
    'host' => EventHostResource::class,
    'attendee' => App\Http\Resources\PersonResource::class,
    'invitee' => EventInviteeResource::class,
    'actor' => EventActorResource::class,
    'venue_owner' => VenueOwnerResource::class,
],

Errors

{message, code} with the package’s status — plus reason for check-in refusals, denials for a refused transition and errors for a validation 422. Messages are translated and never name a class, and your exception handler is never changed:

{ "message": "This code is for another event.", "code": "check_in_refused", "reason": "wrong_event" }
StatuscodeWhen
401(your handler)A write without a signed-in Eloquent user.
403forbidden · invitation_requiredThe policy refused · a private event without an invitation.
404event_not_found, venue_not_found, tier_not_found, rsvp_not_found, invalid_code, invalid_invitationMissing or hidden.
409event_trashed, rsvp_closed, event_full, tier_sold_out, tier_not_on_sale, hold_expired, invalid_rsvp_transition, check_in_refusedA state conflict (check_in_refused adds reason).
422validation_failed, invalid_event, invalid_schedule, invalid_venue, venue_in_use, invalid_tier, tier_key_taken, tier_in_use, tier_required, invalid_guest_count, invalid_organizer, unknown_alias, unknown_transition, transition_denied, invalid_query, tag_not_found, tag_limit_exceeded, invalid_tag, invalid_posterBad input (validation_failed adds errors, transition_denied adds denials).
400invalid_querysort=distance without filter[near].
429transition_denied (+ Retry-After) or the throttleRate limited.

A tag of another scope answers tag_not_found, like a missing one. A poster media-library refuses, or an unknown or expired draft, is invalid_poster.

Rate limits

Defined only when the routes are on, per minute:

LimiterDefaultKeyed byRoutes
events-apiroutes.rate_limits.api = 120user, else IPEvery route (in routes.middleware).
events-rsvprate_limits.rsvp = 20user (or IP) and eventPUT / DELETE /rsvp, the invitation accept / decline.
events-check-inrate_limits.check_in = 300userPOST /check-ins — door scanners are bursty.
events-feedsrate_limits.feeds = 60IPfeed.ics, calendar.ics

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.