Skip to content

v2.0.0-rc.1

Pre-release
Pre-release

Choose a tag to compare

@daveearley daveearley released this 27 Aug 06:35
06d30d2

Hi.Events v2.0.0-rc.1

The biggest release since the project started. v2 adds recurring and multi-date events, embedded
checkout, product add-ons, a rebuilt check-in app, a documented REST API, and a redesigned organizer
dashboard, along with a set of platform upgrades that require action before you upgrade.

Read the Upgrading section before running this on an existing installation. There
are new minimum requirements and several one-way migrations.


Recurring and multi-date events

An event is no longer a single date. Every event now has one or more occurrences, and you can
generate them from a recurrence rule.

  • Daily, weekly, monthly and yearly schedules, with intervals, multiple times per day, and
    per-slot labels and durations. Monthly supports both "the 15th of the month" and "the last
    Friday of the month". The recurrence API additionally accepts excluded dates and one-off extra
    dates.
  • Set a schedule to end after a number of dates or on a specific date. Up to 1,200 occurrences per
    event.
  • Per-date capacity, per-date pricing overrides, per-date product visibility, and per-date venues.
  • Cancel a single date, refund its orders in one action, and notify the attendees. Orders that
    span several dates are left for manual refund. Cancelling writes the date back into the schedule
    as an exclusion so it does not reappear.
  • Bulk edit across dates: shift times, change duration, update capacity, relabel, or change venue,
    with options to only affect future dates and to skip dates you have edited by hand.
  • Attendees, orders, waitlist entries, reports and exports can all be filtered by date.
  • A new Occurrence Summary report breaks down sales, attendance and check-ins per date.

Buyers get a calendar and time-slot picker on the event page and in the embedded widget, with
remaining-capacity hints, sold-out states, and deep links straight to a specific date via
?occurrence_id=.

Single-date events keep working exactly as before. They get one occurrence created automatically and
the buyer never sees a date picker.

Embedded checkout

The embeddable widget now runs the entire checkout in a modal over your own page, from ticket
selection through payment to confirmation.

  • Rendered in a shadow DOM, so your site's CSS cannot break it and it cannot leak styles into your
    page. Full screen on mobile, a centred dialog on desktop.
  • Apple Pay and Google Pay work inside it. Payments that redirect away for authentication (3DS,
    wallets) come back straight into the modal rather than dumping the buyer on a bare page, and the
    redirect parameters are cleaned off your URL afterwards.
  • Closing the modal mid-checkout asks for confirmation and cancels the order. An accidental page
    reload or navigation is safe though: when the buyer returns in the same tab, the checkout reopens
    at the step they left off, as long as the reservation has not expired.
  • Set data-hievents-checkout="new-tab" if you would rather keep the old behaviour of opening
    checkout in a new tab.

The embed snippet has not changed. Existing embeds pick all of this up with no edits to your
site.

There is also a widget playground at /widget-test on dev builds (or in production with
WIDGET_TEST_PAGE_ENABLED=true) for trying colours, locales and checkout modes against a real
event, with a live log of the messages the widget sends to the host page.

Product add-ons

Attach add-ons to a ticket: merch, parking, a workshop upgrade. Add-ons appear nested under their
parent in the widget and stay inert until the parent is in the cart. Mark a product "add-on only"
and it will not appear as a standalone item.

Reusable locations and address autocomplete

Addresses were previously free text stored on each event. They are now a proper location library
per organizer, with a saved-venue picker and Google Places autocomplete.

Autocomplete is optional. Set GOOGLE_MAPS_API_KEY to turn it on. Without a key everything still
works, you just type the address in by hand.

Events can be in-person or online, and recurring events can have a different venue per date. The
public payload now returns a structured event_location object instead of the old
location_details blob.

Rebuilt check-in app

The scanner was reorganised into three tabs: Scan, Search and Stats.

  • Camera scanning is now inline rather than a separate screen, and USB scanner mode shows a live
    listening indicator and a visible input buffer.
  • A stats tab with totals, progress, and a live throughput figure over a rolling five-minute window.
  • An attendee detail sheet with check-in history.
  • A date filter for recurring events, so door staff only see the session they are working.
  • Haptic feedback, an offline indicator, and undo on accidental check-ins.
  • Check-in lists moved to a table view, and you can now control what an unauthenticated check-in
    link exposes: attendee notes, question answers, and order details are each off by default.

Payments moved to the organizer

Stripe Connect, VAT settings and fee configuration used to live on the account. They now live on the
organizer, so one account can run multiple organizers with separate payout destinations.

  • Reuse an existing Stripe connection across organizers in the same account (SaaS mode only), or
    connect each one separately.
  • Disconnect Stripe from an organizer without touching the Stripe account itself.
  • Existing connections are migrated automatically. Nobody has to reconnect.

You can also change an event's currency yourself now, as long as the event has no orders with a
monetary value.

Account deletion

Account owners can request deletion from Account settings, under Danger Zone.

  • 30 day grace period with a reminder email 7 days before the deletion runs, cancellable at any
    point.
  • Accounts that have never taken a completed order are deleted outright. Accounts that have are
    anonymised instead, so financial records stay intact.
  • Blocked while upcoming events still have completed orders, with the blocking events named.
  • Live events are moved to draft as soon as the request is made.

Deletions are executed by an hourly scheduled job, so schedule:run needs to be active.

REST API documentation

The API now ships an OpenAPI spec generated from the code itself.

Set API_DOCS_ENABLED=true and browse interactive docs at /docs/api on your own instance, or
export the spec with php artisan scramble:export. Off by default.

Announcements

Superadmins can post in-app banners or modals to users on their instance, targeted at everyone, at
specific accounts, or at specific users, with seen and dismissed counts. This is entirely local to
your installation. There is no remote feed and nothing is pushed into your install from outside.

Redesigned dashboards

  • Stat cards replaced with sparklines and period-over-period comparisons.
  • A new period selector covering both recent activity (last 7, 30, 90 days) and event lifetime
    (first 7, 30, 90 days, full event).
  • A rebuilt setup checklist with a progress bar, which disappears once you are done.
  • A publish flow that checks for missing pieces before you go live: it warns about missing tickets
    or dates, and blocks publishing when payments are not set up.
  • For recurring events, a hero panel for the next date with a live countdown that turns into a
    check-in shortcut once the date starts.

Checkout

  • The checkout header was rebuilt: a step counter, the event title, and a compact reservation timer
    that turns red as it runs down.
  • Ticket selection was redesigned. One-price tickets show inline without expanding, descriptions
    moved behind a Details toggle, fees and tax have a collapsible breakdown, and low stock shows a
    remaining-quantity hint.
  • The floating "Get tickets" button turns into a checkout button showing your cart total once you
    scroll past the ticket list, and its label is configurable per event.

Everything else

  • Nine new event categories (Family, Hobbies, Wellness, Spirituality, Outdoors, Tours, Film, Dance,
    Seasonal), and the category now sets the default wording. A wellness event says "Classes" and
    "Book Now" rather than "Tickets" and "Continue".
  • Fixed-amount promo codes can now discount the order total rather than each product.
  • Offline payment orders can be refunded, in full or in part. This records the refund and can
    optionally email the buyer, it does not move money.
  • Attendee tickets were redesigned, and cancelled or unpaid tickets now clearly show as void instead
    of displaying a scannable code.
  • Waitlists are date-aware. One person can hold a place on several dates, offers name the date, and
    cancelling a date releases its waitlist.
  • Product create and edit moved into a wide drawer with a live preview of the real widget that
    updates as you type.
  • The frontend loads less up front. Every date-formatting locale used to be bundled whether you used
    it or not and now loads on demand, the rich text editor is loaded only when a page needs it, and
    theme colours are computed on the server rather than in the browser. Event management pages also
    make fewer duplicate API calls.
  • k6 load test scripts (misc/k6/) and a split backend test suite, with the unit suite kept fast
    and database-free.

A comprehensive end-to-end test suite

This release was built alongside a new Playwright E2E suite: over 160 tests across 66 spec files,
driving the real application — Laravel backend, SSR frontend, Postgres, Redis and a mail catcher —
in a real browser. Registration, event creation, free and Stripe checkout, recurring schedules,
promo codes, questions, messaging, check-in, webhooks and more. Test data is arranged through the
REST API and every spec asserts on real page content, not just URL changes.

CI runs a tagged smoke subset on every pull request and the full suite, sharded, on every push. If
you contribute to Hi.Events, e2e/README.md shows how to run it with one command.


Breaking changes

Requirements

v1.11 v2.0
PHP 8.2 8.3
Laravel 12 13
PHP extensions intl intl, gd
Docker base image PHP 8.3 PHP 8.5
Node (SSR image) floating 22

The Docker images handle all of this. Manual installs need to upgrade PHP and install gd first.

Removed API endpoints

Stripe and VAT moved from account level to organizer level:

Removed Replacement
GET /accounts/{id}/stripe/connect_accounts GET /organizers/{id}/stripe/connect_accounts
POST /accounts/{id}/stripe/connect POST /organizers/{id}/stripe/connect
GET /accounts/{id}/vat-settings GET /organizers/{id}/vat-settings
POST /accounts/{id}/vat-settings POST /organizers/{id}/vat-settings
PUT /admin/accounts/{id}/vat-settings PUT /admin/organizers/{id}/vat-settings
PUT /admin/accounts/{id}/configuration PUT /admin/organizers/{id}/configuration

POST /events/{id}/questions/export was removed. It was wired to the orders exporter by mistake.
Use POST /events/{id}/orders/export or /events/{id}/questions/answers/export.

Changed API payloads

  • location_details is gone from event and event-settings payloads, replaced by
    event_location. is_online_event and online_event_connection_details moved inside it.
    OrganizerSettingsResource lost location_details too.
  • Creating or updating an event no longer accepts an inline address. Create a location first
    with POST /organizers/{id}/locations, then pass event_location.location_id.
  • start_date and end_date are still returned but are now derived from an event's
    occurrences. For a recurring event they mean first start and last end within the returned window.
    Sorting and filtering events by these fields is no longer supported, and the default event sort
    changed from start_date asc to created_at desc.
  • events.start_date and events.end_date are no longer read or written. The columns still
    exist but they freeze at the moment you upgrade. If you have reports, dashboards or cron jobs
    querying them directly, point them at event_occurrences.start_date.
  • Checkout accepts event_occurrence_id per product line. Single-date events fill it in
    automatically, so existing integrations keep working. Recurring events require it.
  • Add-on-only products appear in the public products list. Filter on is_addon_only if you
    render products yourself.
  • A fixed-amount promo code scoped to the order can split one order line into two order items
    with the same product_price_id, so the discount divides exactly. Totals are correct to the
    minor unit, but code that keys order items by product_price_id will need adjusting.
  • event_settings.hide_getting_started_page was removed along with the Getting Started page.
  • Image uploads are capped at 5MB, down from 8MB.
  • POST /public/ticket-lookup is now rate limited to 10 requests per minute.

New webhook event

occurrence.cancelled fires when a date is cancelled. No existing events changed.

Behaviour changes

  • A "Default check-in" list is created for every existing event on migration and cannot be
    deleted. It has a public check-in URL (containing an unguessable id) with no activation or expiry
    window, so it is live from the moment you upgrade. It exposes nothing beyond attendee names and
    check-in status unless you turn on the new visibility settings, which all default to off.
  • Existing check-in lists are unbound from any specific date and become all-dates lists.
    Re-scope them if you need to.
  • Duplicate check-in records are collapsed. A unique constraint is added on
    (attendee, check-in list), and duplicates are soft-deleted first. Check-in counts may drop
    slightly if you had duplicates. Nothing is permanently removed.
  • Products with waitlist_enabled unset now block waitlist joins. Previously only an explicit
    false did.
  • Regenerating a schedule deletes dates that no longer match the rule. Dates with orders or
    attendees are preserved and flagged as manually edited, but empty ones are removed.
  • SaaS mode only: the default for passing platform fees to the buyer flipped from off to on. Set
    APP_SAAS_DEFAULT_PASS_PLATFORM_FEE_TO_BUYER=false to keep the old behaviour. Existing
    organizers keep their current setting; the new default applies to organizers created after the
    upgrade.
  • SaaS mode only: stripe_connect_return_url and stripe_connect_refresh_url now take an organizer
    id. If you overrode them, add the %d.

Upgrading

This section covers upgrading a self-hosted installation from v1.11.x to v2.0. If you run Hi.Events
Cloud, there is nothing for you to do — your instance is upgraded for you.

The order matters. Taking the site offline before the backup is what makes the backup a real
rollback point: restore it and you are back exactly where you were, with nothing lost.

  1. Check the requirements
  2. Take the site offline
  3. Drain and stop the queue workers
  4. Back up the database
  5. Upgrade and migrate
  6. Check everything worked

1. Check the requirements

v2 requires PHP 8.3 or higher, up from 8.2. It also needs the gd extension in addition to
intl.

php -v && php -m | grep -E "^(intl|gd)$"

If you use the official Docker images you can skip this. The images ship PHP 8.5 with both
extensions.

2. Take the site offline

Two reasons, and both matter:

  • It makes your backup a real rollback point. A backup taken while the site is still accepting
    orders is stale the moment it finishes — restoring it later would lose every order, attendee and
    check-in written after it. Taken with the site offline, it restores you to exactly the state you
    upgraded from.
  • It removes the one way the migration can fail. Two migrations create unique indexes after
    removing duplicate rows. If traffic writes a conflicting row in the gap, the migration fails and
    you have to retry.

Stop the application but leave the database running — you need it up for the backup and the
migration:

docker compose stop all-in-one

For separate-service setups, stop the web-facing services and leave postgres up — and leave your
queue workers running for now, they are handled in the next step. On a manual install, stop your
app server processes or hold traffic at your reverse proxy.

How long will you be down? On a small installation the whole migration run takes seconds. On a
database with hundreds of thousands of attendees, budget for minutes. Two migrations dominate that
time:

  • 2026_02_22_000006_backfill_occurrences_and_drop_event_dates creates one occurrence per existing
    event, then updates every row in order_items and attendees, then adds a NOT NULL constraint to
    attendees. It runs as a single transaction and holds locks throughout.
  • 2026_07_10_000001_backfill_occurrence_used_capacity counts attendees per occurrence across the
    whole table.

3. Drain and stop the queue workers

Skip this if QUEUE_CONNECTION=sync, or if you use the all-in-one image — stopping the container
in the previous step already stopped its worker.

Jobs queued by v1 do not carry the new occurrence data, so v2 falls back to event-level behaviour
when processing them — emails from a v1 backlog won't show a specific date on a recurring event.
With the site offline, let the workers finish the remaining backlog, then stop them. A restart on
the new code is required regardless: workers cache code in memory and keep running v1 until
restarted.

4. Back up the database

v2 rewrites how event dates are stored, collapses duplicate rows in two tables, and drops one
settings column. Several migrations have down() methods that restore the schema but not the data.
This backup is your only real rollback path.

If postgres runs in Docker (adjust the user and database name if you changed the defaults):

docker compose exec -T postgres pg_dump -U postgres -F c hi-events > hievents-pre-v2-backup.dump

If postgres runs on the host:

pg_dump -U your_user -d your_database -F c -f hievents-pre-v2-backup.dump

5. Upgrade and migrate

All-in-one Docker image

docker compose pull && docker compose up -d

Migrations run automatically on container start, and the site comes back online once they finish.

One important change: if a migration fails, the container now exits instead of starting a
half-migrated application. Because the compose file uses restart: unless-stopped, a failing
migration looks like a restart loop. Check the logs before assuming the image is broken.

docker compose logs -f --tail=200

Docker Compose (separate services)

Pull the new images, then run migrations once before bringing the app back up.

docker compose pull
docker compose run --rm backend php artisan migrate --force
docker compose up -d

Manual install

  1. Upgrade PHP to 8.3 or higher and install the gd extension.
  2. Pull the new code.
  3. Install dependencies:
cd backend && composer install --no-dev --optimize-autoloader
  1. Run migrations:
cd backend && php artisan migrate --force
  1. Build the frontend. Node 22 is what the official images use.
cd frontend && yarn install --frozen-lockfile && yarn build

Build the frontend with yarn, not npm. package.json uses a resolutions block to pin two
prosemirror packages, and npm ignores that field.

  1. Start your app server processes (and queue workers) again on the new code.

After upgrading

Check the scheduler is running

Account deletion is executed by an hourly scheduled job, so schedule:run has to be active for
deletion requests to ever complete. The same applies to waitlist offer expiry and scheduled
messages, both of which predate v2.

* * * * * cd /path/to/backend && php artisan schedule:run >> /dev/null 2>&1

The all-in-one image already runs the scheduler and a queue worker under supervisor. You do not need
to change anything there.

If you run a queue driver other than sync, note that occurrence generation and bulk date
cancellation are queued jobs. They need a worker consuming the queue they are dispatched to.

Verify your data

  • Open an existing event. It should show its original date, now stored as a single occurrence.
  • Check the dashboard on an event with historical sales. If the sales figures are zero but the
    orders are still there, the statistics backfill did not cover that event. Open an issue with the
    event id.
  • Check an event's Location settings. Existing addresses are migrated into the new location library.
  • Check-in lists: every event now has a "Default check-in" list, and any list that was previously
    tied to a specific date is now an all-dates list. Re-scope them if you need to.

Note that the account settings "Payment & Plan" tab no longer exists. Payout settings are now under
Organizer settings, and account settings has a Danger Zone tab in its place.

Optional: enable address autocomplete

v2 can look up addresses through Google Places. This is optional and off unless you provide a key.

GOOGLE_MAPS_API_KEY=your_key_here

Without a key, the address search box only searches your own saved locations and you enter new
addresses manually. Everything else works normally. You do not need a Google Cloud account to run
Hi.Events.

Optional: enable API documentation

API_DOCS_ENABLED=true

Interactive OpenAPI docs are then served at /docs/api. You can also export the spec:

php artisan scramble:export

Known limitation: the all-in-one docker-compose.yml does not currently pass
API_DOCS_ENABLED through to the container, so setting it in .env has no effect there. Add it to
the environment: list manually if you need it.

New environment variables

All are optional and all have safe defaults. Nothing breaks if you add none of them.

Variable Default What it does
GOOGLE_MAPS_API_KEY unset Turns on Google Places address autocomplete.
GEO_PROVIDER google Geo provider. Only google is implemented.
API_DOCS_ENABLED false Serves interactive API docs at /docs/api.
OCCURRENCES_QUEUE_NAME unset Sends occurrence jobs to a dedicated queue.

If you set OCCURRENCES_QUEUE_NAME, add that queue name to your worker's --queue= list too.

Rolling back

Treat v2 as forward-only. Restore your database backup if you need to go back — because it was
taken with the site offline, restoring it puts you back exactly where you were before the upgrade.

The migrations do define down() methods, but several cannot restore what they changed:

  • The two deduplicating migrations remove duplicate rows and only drop the index on rollback.
  • Rolling back the event category change rewrites any event using one of the nine new categories to
    OTHER.
  • hide_getting_started_page is recreated with a default value, not its previous values.
  • Events created under v2 never write events.start_date, so on a v1 rollback those events would
    appear with no date.

Troubleshooting

The container keeps restarting after upgrade.
A migration failed. v2 stops the container rather than serving a half-migrated app. Read the logs
for the actual error.

Creating a recurring schedule never finishes.
You are on a non-sync queue driver with nothing consuming the queue the occurrence jobs were
dispatched to. Check your worker, and check OCCURRENCES_QUEUE_NAME if you set it.

A unique index migration failed.
Traffic wrote a conflicting row between the cleanup and the index build. Stop the application and
re-run php artisan migrate --force.

composer install fails on the PHP version.
You are on PHP 8.2 or lower. v2 requires 8.3 or higher.

Address search shows no suggestions.
Expected without GOOGLE_MAPS_API_KEY. Use "Enter address manually".


Thanks to everyone who tested the alpha and filed issues, and to the contributors whose work is
part of this release: @mrjbj, @officialJCReyes, @dalsoop, @Viburnum, @albanobattistella and
@manaskumar3003.

Full changelog: v1.11.1-beta...v2.0.0-rc.1