Creating and editing events
Events::make($title) returns an EventBuilder: create() stores a draft, publish() creates and publishes in one transaction. A title, an organizer and a start are all it needs:
use RoundlyConsulting\Events\Enums\AttendanceMode;
use RoundlyConsulting\Events\Enums\GuestList;
use RoundlyConsulting\Events\Enums\Visibility;
use RoundlyConsulting\Events\Facades\Events;
$event = Events::make(['en' => 'Jazz Night', 'sk' => 'Jazzový večer']) // EventBuilder
->description(['en' => 'A live quartet.'])
->slug(['en' => 'jazz-night']) // optional manual slugs (strict: a taken one throws)
->organizedBy($user) // Model&Organizer
->coHostedBy($partnerTeam)
->at($venue) // Venue, a venue id, or null
->online('https://meet.example.com/jazz') // stored encrypted; managers and going attendees only
->attendanceMode(AttendanceMode::Mixed) // else derived: venue → offline, link → online, both → mixed
->timezone('Europe/Bratislava')
->startsAt('2026-11-14 20:00')->duration('PT3H') // or ->endsAt(), or ->allDay($from, through: $to)
->doorsOpenAt('2026-11-14 19:00')
->visibility(Visibility::Unlisted) // or ->unlisted(), ->private()
->capacity(120)->maxGuests(2)->waitlist(true, autoPromote: true)
->rsvpWindow(opensAt: '2026-10-01 09:00', closesAt: '2026-11-14 18:00')
->guestList(GuestList::Attendees)->showCounts(true)
->category('jazz')->labels(['Live', 'Late night'])
->poster($upload) // UploadedFile, a local path, or draft: $token
->tier('Free entry', key: 'free')
->reminders(['P1D', 'PT2H'])
->meta(['source' => 'import'])
->by($user) // checks create (and organizer approval)
->create(); // a draft — or ->publish(): create + publish in one transactionSetters validate eagerly where they can — timezone, durations, URL, guests ≥ 0. Organizing as anyone but the actor, such as a team, needs $organizer->allowsEventManagementBy($actor), else InvalidOrganizerException. Every setter is listed on The Events facade.
- A title is at most 255 characters per locale (200 over the API), a description at most 20,000 and a join link at most 2048 (http or https).
- Control characters are stripped from titles and names; descriptions keep line breaks and tabs.
- Without a venue or a link the event is offline with the place to be announced. Otherwise the attendance mode is derived — a venue is offline, a link online, both mixed.
- Defaults come from events.defaults.*: visibility, guests per RSVP, guest list, counts, waitlist (with automatic promotion on), duration and timezone.
The DTO form
use RoundlyConsulting\Events\DataTransferObjects\CreateEventData;
use RoundlyConsulting\Events\DataTransferObjects\ScheduleData;
use RoundlyConsulting\Events\DataTransferObjects\TierData;
use RoundlyConsulting\Events\Facades\Events;
$event = Events::create(new CreateEventData(
title: ['en' => 'Jazz Night'],
organizer: $user,
schedule: new ScheduleData(startsAt: '2026-11-14 20:00', duration: 'PT3H', timezone: 'Europe/Bratislava'),
capacity: 120,
tiers: [new TierData(name: 'Free entry', key: 'free')],
actor: $user,
));From the organizer
// OrganizesEvents: the builder with the organizer already set
$event = $team->organizeEvent(['en' => 'Hack night'])
->at($venue)
->startsAt('2026-12-03 18:00')
->by($user) // the team must allow $user (allowsEventManagementBy)
->publish();Editing
use RoundlyConsulting\Events\DataTransferObjects\UpdateEventData;
use RoundlyConsulting\Events\Enums\GuestList;
Events::for($event)->by($user)->edit()
->description(null) // null clears a nullable field
->doorsOpenAt('2026-11-14 19:00')
->guestList(GuestList::Attendees)
->showCounts(false)
->save();
Events::for($event)->by($user)->edit()->title('Renamed')->capacity(150)->save();
Events::for($event)->update(new UpdateEventData(capacity: 150, description: null)); // the DTO form- Only the fields given change: every UpdateEventData field defaults to NotGiven, and null clears a nullable one. The editor’s rsvpWindow() changes only the bounds given; waitlist($on) leaves automatic promotion alone unless autoPromote: is passed.
- A new start (or a new all-day first day) on an announced event is refused with invalid_event — use reschedule(). Repeating the current start, as an edit form sending every field back does, is an ordinary update.
- A change of the start, end, all-day flag, timezone, venue, join link or attendance mode raises the calendar SEQUENCE and, on an announced event, notifies going and held attendees — unless silently().
- A higher capacity, or waitlistAutoPromote switched back on, moves the waitlist up. EventUpdated lists the changed attributes; nothing fires when nothing changed.
Visibility
| Visibility | Who sees it |
|---|---|
public | Everyone, once announced. |
unlisted | Anyone with the uuid link; findBySlug() only for managers, invitees and attendees. Never listed or in feeds. |
private | Managers, invitees (pending or accepted) and anyone with an active RSVP. Never listed or in feeds. |
Drafts are visible to managers only. The full matrix and the policy are on Privacy and authorization.
Finding and listing
Events::find($uuid); // live events only; no visibility rule — authorize view yourself
Events::findOrFail($uuid); // EventNotFoundException otherwise
Events::findBySlug('jazz-night', $viewer); // along the locale chain; null for what $viewer may not see
Events::for($uuid); // a handle; an unknown or trashed uuid throws EventNotFoundException
Events::browse($viewer)->upcoming()->paginate(); // visible, listed (public), announced
Events::visibleTo($viewer)->get(); // the policy's view as a query — managed drafts includedPer-locale slugs come from the title and are stable once set, so a renamed event keeps its links. They are unique per locale with trashed events included and get a sequential suffix on collision — a manual slug() never does: a taken one throws. With events.slugs.history on, old slugs keep redirecting. Route-model binding uses the uuid, never the id or the slug.
Poster
Events::for($event)->by($organizer)->poster($request->file('poster')); // an UploadedFile or a local path
Events::for($event)->poster(draft: $token); // media-library's upload-first flow
Events::for($event)->poster(); // no file and no draft removes it
$event->poster; // ?Media
$event->posterUrl('card'); // the variant once generated, else the original, else events.poster.fallback_urlThe poster bucket takes one JPEG, PNG, WebP or AVIF image within events.poster.max_kb and at least 600×315 — public by nature, with queued webp variants (thumb, card, square, landscape, wide). posterUrl() never throws: a pending variant falls back to the original, no poster to events.poster.fallback_url. PosterChanged fires; a force delete removes the poster files and a soft delete keeps them for a restore.
Transfer, delete and restore
Events::for($event)->by($organizer)->transferTo($team, keepAsHost: true);
Events::for($event)->by($organizer)->delete(); // soft; refused while people are going (postpone or cancel first)
Events::for($event)->by($organizer)->restore();
Events::for($event)->delete(force: true); // the row, its RSVPs, ticket types, co-hosts, invitations, tags and poster files- transferTo() needs the transfer ability — the organizer’s approved users or an admin — and a new organizer who allows the actor, unless the actor is an admin. See Organizers, co-hosts and invitations.
- delete() is soft and refused (invalid_event) while an announced, unfinished event has people going or holding seats — postpone or cancel first. delete(force: true) follows the same rule and removes the row, a trashed one too, with its RSVPs, ticket types, co-hosts, invitations, tags and poster files.
- A trashed event is read-only: every write throws EventTrashedException (409) until restore(). EventDeleted (with $force) and EventRestored fire.
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.