JSON API
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 · path | Name | Ability | Notes |
|---|---|---|---|
GET /events | events.index | viewAny | Guests too. Discovery filters below → paginated EventResource. |
GET /events/feed.ics | events.feed | — | Guests too. ?category=&label=&organizer=alias:key&venue= → text/calendar (inline); throttle events-feeds. |
GET /events/{event} | events.show | view | Guests too. {data: EventResource} with hosts and viewer.can. |
GET /events/{event}/calendar.ics | events.calendar | view | Guests too. text/calendar (attachment); the join link only for a viewer who may see it. |
GET /events/{event}/tiers | events.tiers.index | view | Guests too. Hidden ones for managers only. |
GET /events/{event}/attendees | events.attendees.index | viewAttendees | Guests too. ?status=&checked_in=&tier=&per_page= (default 25) — managers only for the filters. |
GET /events/{event}/hosts | events.hosts.index | view | Guests too. {data: [host reference]}. |
GET /event-venues | events.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 · decline | events.invitations.accept · decline | the token | Guests too. {token, response?} (going default, interested) → RsvpResource (owner view) / {status: declined}; throttle events-rsvp. |
POST /events | events.store | create | 201 {data: EventResource}. |
PATCH /events/{event} · DELETE /events/{event} | events.update · destroy | update · delete | Only the fields sent change; ?notify=0 writes silently · 204. |
POST /events/{event}/restore | events.restore | restore | Trashed events, managers only (others 404). |
POST /events/{event}/publish · unpublish · complete | events.publish · … | publish · … | {data: EventResource} |
POST /events/{event}/postpone · cancel | events.postpone · cancel | postpone · cancel | {reason?} (≤ 500). |
POST /events/{event}/reschedule | events.reschedule | reschedule | {starts_at, ends_at?, reason?} |
POST /events/{event}/move-online | events.move-online | moveOnline | {url, keep_venue?} |
PUT · DELETE /events/{event}/poster | events.poster.update · destroy | update | Multipart poster or a draft token · removes it. |
POST /events/{event}/tiers · tiers/reorder | events.tiers.store · reorder | manageTiers | 201 AdmissionTierResource · {order: [tier id, …]} → 204. |
PATCH · DELETE /events/{event}/tiers/{tier} | events.tiers.update · destroy | manageTiers | A tier by id or key; only the fields sent · 204. |
GET /events/{event}/rsvp · rsvp/qr.svg | events.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}/rsvp | events.rsvp.update · destroy | rsvp | {response, guests?, tier?, note?} · answers not_going; throttle events-rsvp. |
POST /events/{event}/attendees | events.attendees.store | manageAttendees | {attendee: {type, id}, tier?, guests?, over_capacity?} → 201 |
PATCH · DELETE /events/{event}/attendees/{rsvp} | events.attendees.update · destroy | manageAttendees | {tier?, guests?} — both or neither applies · 204. |
POST · DELETE /events/{event}/attendees/{rsvp}/check-in | events.attendees.check-in · undo-check-in | checkIn | Manual check-in {guests?} (no code) · undo. |
POST /events/{event}/check-ins | events.check-ins.store | checkIn | A door scan {code, guests?} → CheckInResultResource, 201 first, 200 repeat; throttle events-check-in. |
GET /events/{event}/waitlist · POST …/waitlist/promote | events.waitlist.index · promote | viewWaitlist · manageAttendees | In promotion order · {limit?} (1–1000). |
POST · DELETE /events/{event}/hosts… | events.hosts.store · destroy | manageHosts | {person: {type, id}} (an Organizer) → 201 · {host} = alias:key → 204. |
GET · POST · DELETE /events/{event}/invitations… | events.invitations.index · store · destroy | invite | Tokens never rendered · {person: {type, id}, message?} (≤ 1000) → 201 · {invitee} = alias:key → 204. |
POST · PATCH · DELETE /event-venues… | events.venues.store · update · destroy | VenuePolicy | Venue body below → 201 · only the fields sent · 204. |
POST /event-venues/{venue}/geocode | events.venues.geocode | VenuePolicy geocode | Always 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
}| Field | Rules |
|---|---|
title | Required; 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 · venue | An 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_through | starts_at required unless all_day_from; all-day dates Y-m-d. |
capacity · max_guests | 0–10,000,000 · 0–1000 |
waitlist · waitlist_auto_promote · show_counts · publish | Booleans; publish: true announces it in the same transaction. |
rsvp_opens_at · rsvp_closes_at · reminders | Strings; ≤ 5 reminders. |
category · labels | A 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_draft | A 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:
| Parameter | Scope |
|---|---|
filter[when]=upcoming|ongoing|past | upcoming() … |
filter[from], filter[to] | Overlapping the range, wall clock in ?tz= (default app.timezone). |
filter[date]=2026-11-14 | onLocalDate() — a real calendar date (2027-02-30 is a 422). |
filter[near]=48.14,17.11,25 | near() — radius optional, ≤ geo.max_radius_km. |
filter[category]=jazz | inCategory() with sub-categories. |
filter[labels]=live,outdoor | Every label. |
filter[organizer]=user:42 | organizedBy() — the row is never looked up; a missing one matches nothing. |
filter[venue]=7,8 · filter[mode]=online,mixed | atVenue() · withAttendanceMode() |
filter[free]=true · filter[available]=true | free() · withSeatsAvailable() |
filter[search]=… | search() — escaped contains-search on title and description (commas kept). |
filter[status]=postponed,rescheduled | inPhase() |
filter[mine]=organizing|attending|interested | Your own events, every visibility (organizing includes drafts); guests get none. |
sort=starts_at (default), -starts_at, created_at, -created_at, popularity, distance | distance needs filter[near] (400 otherwise); popularity ranks an event with show_counts off as zero going — except under filter[mine]=organizing. |
per_page | Default 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"
}
}| Resource | Shape |
|---|---|
EventResource | See 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. |
VenueResource | id, 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 |
AdmissionTierResource | id, 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. |
RsvpResource | id, 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. |
InvitationResource | id, status, invitee, invited_by, message, expires_at (null while postponed), responded_at, sent_at — never the token. |
CheckInResultResource | first_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 key | Default | Renders |
|---|---|---|
events.resources.organizer | EventOrganizerResource | organizer |
events.resources.host | EventHostResource | co-hosts |
events.resources.attendee | EventAttendeeResource | attendees |
events.resources.invitee | EventInviteeResource | invitees |
events.resources.actor | EventActorResource | invited_by, checked_in_by |
events.resources.venue_owner | VenueOwnerResource | a 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" }| Status | code | When |
|---|---|---|
| 401 | (your handler) | A write without a signed-in Eloquent user. |
| 403 | forbidden · invitation_required | The policy refused · a private event without an invitation. |
| 404 | event_not_found, venue_not_found, tier_not_found, rsvp_not_found, invalid_code, invalid_invitation | Missing or hidden. |
| 409 | event_trashed, rsvp_closed, event_full, tier_sold_out, tier_not_on_sale, hold_expired, invalid_rsvp_transition, check_in_refused | A state conflict (check_in_refused adds reason). |
| 422 | validation_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_poster | Bad input (validation_failed adds errors, transition_denied adds denials). |
| 400 | invalid_query | sort=distance without filter[near]. |
| 429 | transition_denied (+ Retry-After) or the throttle | Rate 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:
| Limiter | Default | Keyed by | Routes |
|---|---|---|---|
events-api | routes.rate_limits.api = 120 | user, else IP | Every route (in routes.middleware). |
events-rsvp | rate_limits.rsvp = 20 | user (or IP) and event | PUT / DELETE /rsvp, the invitation accept / decline. |
events-check-in | rate_limits.check_in = 300 | user | POST /check-ins — door scanners are bursty. |
events-feeds | rate_limits.feeds = 60 | IP | feed.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 cryptoBy 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.