Organizers, co-hosts and invitations
Organizers
The organizer is any model implementing Contracts\Organizer — a user, a team, a venue company. Concerns\OrganizesEvents provides the defaults:
| Method | Default |
|---|---|
eventOrganizerName(): string | The name attribute, else the key (JSON-LD, calendar files, notices). |
eventOrganizerUrl(): ?string | null (JSON-LD organizer.url). |
allowsEventManagementBy(Model $user): bool | Only itself. |
scopeManagingEventsFor(Builder $query, Model $user) | Only itself. |
eventOrganizerEmail(): ?string | Optional, not in the contract — when present, calendar files carry ORGANIZER;CN=…:mailto:…. |
It also adds organizedEvents(), coHostedEvents() and organizeEvent($title), which is Events::make($title)->organizedBy($this):
$user->organizedEvents()->upcoming()->get();
$user->coHostedEvents()->with('event')->get();
Event::query()->managedBy($user)->get(); // every event the user manages, drafts includedTeams as organizers
Override the two management methods together — membership in PHP and in SQL — so managedBy() and the policy agree:
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use RoundlyConsulting\Events\Concerns\OrganizesEvents;
use RoundlyConsulting\Events\Contracts\Organizer;
final class Team extends Model implements Organizer
{
use OrganizesEvents;
public function allowsEventManagementBy(Model $user): bool
{
return $this->members()->whereKey($user->getKey())->exists();
}
public function scopeManagingEventsFor(Builder $query, Model $user): void
{
$query->whereHas('members', fn (Builder $members) => $members->whereKey($user->getKey()));
}
}Then list every organizer class under events.organizers. The SQL side — managedBy(), and through it visibleTo(), browse() and the API’s filter[mine]=organizing — asks each listed class, plus the user’s own class, for scopeManagingEventsFor(). A team class missing from the list still passes the policy in PHP, but its members never see its drafts and private events in lists. The teams-for-laravel version is on Recipes.
Who manages an event
Managers are the organizer’s approved users, each co-host’s approved users and holders of events.authorization.abilities.admin (events.admin; undefined means nobody; an admin must be an Authenticatable). Organizing as a team needs the team’s approval for the actor, else InvalidOrganizerException.
Co-hosts and transfer
Events::for($event)->by($organizer)->hosts()->add($partnerTeam); // manageHosts
Events::for($event)->hosts()->remove($partnerTeam);
Events::for($event)->hosts()->all(); // EventHost rows with ->host
Events::for($event)->hosts()->has($partnerTeam);
Events::for($event)->by($organizer)->transferTo($newOrganizer, keepAsHost: true);- A co-host must be an Organizer (remove() takes any model). Co-hosts manage the event — ticket types, attendees, check-in — but cannot add or remove co-hosts or transfer the event: those are the organizer’s (manageHosts, transfer). CoHostAdded and CoHostRemoved fire; co-hosts can also be named at creation with ->coHostedBy().
- transferTo() hands the event over and fires OrganizerTransferred. The new organizer must allow the actor unless the actor is an admin. A new organizer who was a co-host stops being one; keepAsHost makes the old organizer a co-host. Transferring to the current organizer changes nothing.
- With events.tags.scope = organizer the event’s labels move into the new organizer’s catalogue in the same transaction — found there by name, or created with every translation.
Invitations
$invitation = Events::for($event)->by($organizer)->invitations()->send($user, 'See you there!'); // invite
$invitation->plainToken; // once — the accept link's token; only its hash is stored
Events::for($event)->invitations()->revoke($user);
Events::for($event)->invitations()->all(); // ->pending(); invite with by($actor)- On a private event an invitation is the admission; on a public or unlisted one it is an “invite a friend” notice.
- Sending again mints a new token and the old one dies — a revoked or declined invitation becomes pending again, an accepted one stays accepted. A message is at most 1000 characters.
- A link expires at the event’s current start ($invitation->expiresAt(), read from the schedule, never stored): a reschedule moves it, and while the event is postponed it does not expire.
- Invitations sent while the event is a draft go out when it is published — each pending one gets a fresh token and InvitationSent fires again; a token taken from plainToken on the draft stops working then.
- Revoking clears the token. On a private event it also takes away the invitee’s place unless they are already going: an interested, waitlisted or held RSVP becomes not_going, with the seats back and the waitlist promoted.
- InvitationSent carries the token for the mail. The queued invited notice is encrypted (ShouldBeEncrypted), so jobs and failed_jobs never hold it in the clear, and the database channel never stores it — encrypt your own queued listeners of InvitationSent the same way.
Answering from the link
use RoundlyConsulting\Events\Enums\RsvpResponse;
Events::invitations()->find($token); // the live invitation, or null
Events::invitations()->accept($token, RsvpResponse::Going, by: $request->user()); // RSVPs the invitee
Events::invitations()->decline($token, by: $request->user());
Events::invitations()->asSystem()->accept($token); // trusted PHP: imports, seeders, testsThe token is a bearer secret used once, and the attendee is always the invitation’s own invitee, never the caller:
| Invitee | Who may answer |
|---|---|
| An account (an Authenticatable, e.g. your User) | Only that user, signed in: by must be the invitee. Signed out or signed in as someone else is refused — a forwarded mail cannot answer for them, nor see their check-in code. |
| Not an account (a Guest model — see Recipes) | Whoever holds the link: the token is the credential. |
| Any, from asSystem() | Trusted code, no user needed. |
The check runs before anything is written, so a refused answer leaves the token usable for the real invitee. The answer and the invitation write share one transaction, with the invitation re-read under its lock: a revocation lands either before (the answer is refused) or after (it stands) — never in between. Unknown, malformed, revoked, expired and used tokens and someone else’s link all read the same: InvalidInvitationException, a uniform 404. InvitationAccepted, InvitationDeclined and InvitationRevoked fire.
Attendees without accounts
Any model can attend — list it under events.attendees, with the same key type as your users. Recipes has a Guest model for people your app knows only by name and e-mail.
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.