{"_id":"@artisanpack-ui/bookings-js","_rev":"4-983d54d17a1c79501639255b493e6cc7","name":"@artisanpack-ui/bookings-js","dist-tags":{"latest":"1.2.2"},"versions":{"1.0.0":{"name":"@artisanpack-ui/bookings-js","version":"1.0.0","author":{"name":"Jacob Martella","email":"me@jacobmartella.com"},"license":"MIT","_id":"@artisanpack-ui/bookings-js@1.0.0","maintainers":[{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"}],"homepage":"https://github.com/ArtisanPack-UI/bookings#readme","bugs":{"url":"https://github.com/ArtisanPack-UI/bookings/issues"},"dist":{"shasum":"ae3a174911c9028645ef321b3716deb956a1b378","tarball":"https://registry.npmjs.org/@artisanpack-ui/bookings-js/-/bookings-js-1.0.0.tgz","fileCount":33,"integrity":"sha512-GikCkwKTrtQGW65MUNooiAU+T68adrlF13HEuigLe2EqqshbhDM12akXxPvR5a0qChe2d9zu6v9WyMYHxAdkxw==","signatures":[{"sig":"MEYCIQCUCa+we42HxkX/n1PMfYaOl5GJGnJSgp0qrUjmHwL9dQIhAI7xHxxnpHTX4CyRivvfVAeHClTv0h0X1JdHtJWTtrjN","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@artisanpack-ui%2fbookings-js@1.0.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":354951},"main":"./dist/core/index.js","type":"module","types":"./dist/core/index.d.ts","exports":{".":{"types":"./dist/core/index.d.ts","import":"./dist/core/index.js"},"./vue":{"types":"./dist/vue/index.d.ts","import":"./dist/vue/index.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"}},"gitHead":"63757bd2f155415f864d6eadb077927fc56d39d6","scripts":{"demo":"npm run build && vite --config vite.demo.config.ts","test":"vitest run","build":"vite build && tsc -p tsconfig.build.json --emitDeclarationOnly","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"},"repository":{"url":"git+https://github.com/ArtisanPack-UI/bookings.git","type":"git"},"_npmVersion":"10.8.2","description":"Framework-agnostic JavaScript core for the ArtisanPack UI bookings package, plus ready-made React and Vue booking widgets: typed public API client, date and timezone helpers, shared types, and the BookingWidget and ManageBooking components.","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vue":"^3.5.0","vite":"^5.4.21","jsdom":"^25.0.0","react":"^19.0.0","rollup":"^4.62.4","vitest":"^2.1.0","react-dom":"^19.0.0","typescript":"^5.6.0","@types/react":"^19.0.0","@vue/test-utils":"^2.4.6","@types/react-dom":"^19.0.0","@testing-library/dom":"^10.4.0","@testing-library/react":"^16.1.0"},"peerDependencies":{"vue":"^3.5.0","react":"^18.0.0 || ^19.0.0","react-dom":"^18.0.0 || ^19.0.0"},"peerDependenciesMeta":{"vue":{"optional":true},"react":{"optional":true},"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/bookings-js_1.0.0_1787423022128_0.6111286946525418","host":"s3://npm-registry-packages-npm-production"}},"1.1.0":{"name":"@artisanpack-ui/bookings-js","version":"1.1.0","author":{"name":"Jacob Martella","email":"me@jacobmartella.com"},"license":"MIT","_id":"@artisanpack-ui/bookings-js@1.1.0","maintainers":[{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"}],"homepage":"https://github.com/ArtisanPack-UI/bookings#readme","bugs":{"url":"https://github.com/ArtisanPack-UI/bookings/issues"},"dist":{"shasum":"0c8272230078beca3a057ee86b5e9c1ae813697b","tarball":"https://registry.npmjs.org/@artisanpack-ui/bookings-js/-/bookings-js-1.1.0.tgz","fileCount":33,"integrity":"sha512-UVJM5CYbpVEp6VjTn+vDXLRjW5/kpkVKEXLCRoW1C1h9eY9v8Y+ZRVIj37DU66fMlC/u7gTbIulLAxH/ajduNw==","signatures":[{"sig":"MEUCIGFwNF+TfJKn1qQtJ7F5+9kIy0OmRKpKFK3u5m3pDQ3HAiEAzjXR1JGl7pX3MtbtVSi+6M6VXNnIFyCJl+QsUY4ATd0=","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@artisanpack-ui%2fbookings-js@1.1.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":357060},"main":"./dist/core/index.js","type":"module","types":"./dist/core/index.d.ts","exports":{".":{"types":"./dist/core/index.d.ts","import":"./dist/core/index.js"},"./vue":{"types":"./dist/vue/index.d.ts","import":"./dist/vue/index.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"}},"gitHead":"2e81fe2abc90a9b7a04217673828664cd61cdcbd","scripts":{"demo":"npm run build && vite --config vite.demo.config.ts","test":"vitest run","build":"vite build && tsc -p tsconfig.build.json --emitDeclarationOnly","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"},"repository":{"url":"git+https://github.com/ArtisanPack-UI/bookings.git","type":"git"},"_npmVersion":"10.8.2","description":"Framework-agnostic JavaScript core for the ArtisanPack UI bookings package, plus ready-made React and Vue booking widgets: typed public API client, date and timezone helpers, shared types, and the BookingWidget and ManageBooking components.","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vue":"^3.5.0","vite":"^5.4.21","jsdom":"^25.0.0","react":"^19.0.0","rollup":"^4.62.4","vitest":"^2.1.0","react-dom":"^19.0.0","typescript":"^5.6.0","@types/react":"^19.0.0","@vue/test-utils":"^2.4.6","@types/react-dom":"^19.0.0","@testing-library/dom":"^10.4.0","@testing-library/react":"^16.1.0"},"peerDependencies":{"vue":"^3.5.0","react":"^18.0.0 || ^19.0.0","react-dom":"^18.0.0 || ^19.0.0"},"peerDependenciesMeta":{"vue":{"optional":true},"react":{"optional":true},"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/bookings-js_1.1.0_1788142457423_0.624797686939029","host":"s3://npm-registry-packages-npm-production"}},"1.2.0":{"name":"@artisanpack-ui/bookings-js","version":"1.2.0","author":{"name":"Jacob Martella","email":"me@jacobmartella.com"},"license":"MIT","_id":"@artisanpack-ui/bookings-js@1.2.0","maintainers":[{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"}],"homepage":"https://github.com/ArtisanPack-UI/bookings#readme","bugs":{"url":"https://github.com/ArtisanPack-UI/bookings/issues"},"dist":{"shasum":"a3560669b529cb64a2dba3bf3f93121e24c05c7e","tarball":"https://registry.npmjs.org/@artisanpack-ui/bookings-js/-/bookings-js-1.2.0.tgz","fileCount":40,"integrity":"sha512-sJwN6mLyB5tQ/FhjS8O3BsPM+s0v9OqszhkSPlWAreYAIX4/iG4rProm1VhtHhSFBBDktYvJvuJ/AgJQ3ypoAw==","signatures":[{"sig":"MEQCIGm5tHmdy4Wa8GQoeukWM+mwLwVvFdYLdjXqEyvdcqp8AiBvsh2mICP5E7R266hVG0fd58OMJhnpyqs1rygeMMMNIw==","keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"}],"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@artisanpack-ui%2fbookings-js@1.2.0","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"unpackedSize":418417},"main":"./dist/core/index.js","type":"module","types":"./dist/core/index.d.ts","exports":{".":{"types":"./dist/core/index.d.ts","import":"./dist/core/index.js"},"./vue":{"types":"./dist/vue/index.d.ts","import":"./dist/vue/index.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"}},"gitHead":"27675b4d8d6359274204c49ae1dea016e3c05671","scripts":{"demo":"npm run build && vite --config vite.demo.config.ts","test":"vitest run","build":"vite build && tsc -p tsconfig.build.json --emitDeclarationOnly","typecheck":"tsc --noEmit","test:watch":"vitest","prepublishOnly":"npm run build"},"_npmUser":{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"},"repository":{"url":"git+https://github.com/ArtisanPack-UI/bookings.git","type":"git"},"_npmVersion":"10.8.2","description":"Framework-agnostic JavaScript core for the ArtisanPack UI bookings package, plus ready-made React and Vue booking widgets: typed public API client, date and timezone helpers, shared types, and the BookingWidget and ManageBooking components.","directories":{},"sideEffects":false,"_nodeVersion":"20.20.2","publishConfig":{"access":"public"},"_hasShrinkwrap":false,"devDependencies":{"vue":"^3.5.0","vite":"^5.4.21","jsdom":"^25.0.0","react":"^19.0.0","rollup":"^4.62.4","vitest":"^2.1.0","react-dom":"^19.0.0","typescript":"^5.6.0","@types/react":"^19.0.0","@vue/test-utils":"^2.4.6","@types/react-dom":"^19.0.0","@testing-library/dom":"^10.4.0","@testing-library/react":"^16.1.0"},"peerDependencies":{"vue":"^3.5.0","react":"^18.0.0 || ^19.0.0","react-dom":"^18.0.0 || ^19.0.0"},"peerDependenciesMeta":{"vue":{"optional":true},"react":{"optional":true},"react-dom":{"optional":true}},"_npmOperationalInternal":{"tmp":"tmp/bookings-js_1.2.0_1788194532189_0.7881911643380992","host":"s3://npm-registry-packages-npm-production"}},"1.2.2":{"name":"@artisanpack-ui/bookings-js","version":"1.2.2","description":"Framework-agnostic JavaScript core for the ArtisanPack UI bookings package, plus ready-made React and Vue booking widgets: typed public API client, date and timezone helpers, shared types, and the BookingWidget and ManageBooking components.","type":"module","license":"MIT","author":{"name":"Jacob Martella","email":"me@jacobmartella.com"},"main":"./dist/core/index.js","types":"./dist/core/index.d.ts","exports":{".":{"types":"./dist/core/index.d.ts","import":"./dist/core/index.js"},"./react":{"types":"./dist/react/index.d.ts","import":"./dist/react/index.js"},"./vue":{"types":"./dist/vue/index.d.ts","import":"./dist/vue/index.js"}},"sideEffects":false,"repository":{"type":"git","url":"git+https://github.com/ArtisanPack-UI/bookings.git"},"homepage":"https://github.com/ArtisanPack-UI/bookings#readme","bugs":{"url":"https://github.com/ArtisanPack-UI/bookings/issues"},"publishConfig":{"access":"public"},"scripts":{"build":"vite build && tsc -p tsconfig.build.json --emitDeclarationOnly","typecheck":"tsc --noEmit","test":"vitest run","test:watch":"vitest","demo":"npm run build && vite --config vite.demo.config.ts","prepublishOnly":"npm run build"},"peerDependencies":{"react":"^18.0.0 || ^19.0.0","react-dom":"^18.0.0 || ^19.0.0","vue":"^3.5.0"},"peerDependenciesMeta":{"react":{"optional":true},"react-dom":{"optional":true},"vue":{"optional":true}},"devDependencies":{"@testing-library/dom":"^10.4.0","@testing-library/react":"^16.1.0","@types/react":"^19.0.0","@types/react-dom":"^19.0.0","@vue/test-utils":"^2.4.6","jsdom":"^25.0.0","react":"^19.0.0","react-dom":"^19.0.0","rollup":"^4.62.4","typescript":"^5.6.0","vite":"^5.4.21","vitest":"^2.1.0","vue":"^3.5.0"},"_id":"@artisanpack-ui/bookings-js@1.2.2","gitHead":"edba8f315fa6688c67d33792c4a9c338c4e95f4b","_nodeVersion":"20.20.2","_npmVersion":"10.8.2","dist":{"integrity":"sha512-GHXDu8IIPbJfUaKSzrPzDJ04kZU6VDUCinfRNK6L3Zq3AxrBgUkVNNPJ2oHJ8V/ivwlxnUDGGQ+6wIr5QpGhQQ==","shasum":"8702e7d104e4a9c1e52eab48f785a7280ec9d31e","tarball":"https://registry.npmjs.org/@artisanpack-ui/bookings-js/-/bookings-js-1.2.2.tgz","fileCount":40,"unpackedSize":418415,"attestations":{"url":"https://registry.npmjs.org/-/npm/v1/attestations/@artisanpack-ui%2fbookings-js@1.2.2","provenance":{"predicateType":"https://slsa.dev/provenance/v1"}},"signatures":[{"keyid":"SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U","sig":"MEUCIGPLsZe0gt6JLc6jKLGVAD1uqAuf9oo43YiiuF+StEzQAiEAlnLfG9WDEnqKQOIiiI2+SHe7zEbDaCJT49989chq8V0="}]},"_npmUser":{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"},"directories":{},"maintainers":[{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"}],"_npmOperationalInternal":{"host":"s3://npm-registry-packages-npm-production","tmp":"tmp/bookings-js_1.2.2_1788212620863_0.5147155965769581"},"_hasShrinkwrap":false}},"time":{"created":"2026-08-22T18:23:41.973Z","modified":"2026-08-31T21:43:41.365Z","1.0.0":"2026-08-22T18:23:42.296Z","1.1.0":"2026-08-31T02:14:17.551Z","1.2.0":"2026-08-31T16:42:12.363Z","1.2.2":"2026-08-31T21:43:41.039Z"},"bugs":{"url":"https://github.com/ArtisanPack-UI/bookings/issues"},"author":{"name":"Jacob Martella","email":"me@jacobmartella.com"},"license":"MIT","homepage":"https://github.com/ArtisanPack-UI/bookings#readme","repository":{"type":"git","url":"git+https://github.com/ArtisanPack-UI/bookings.git"},"description":"Framework-agnostic JavaScript core for the ArtisanPack UI bookings package, plus ready-made React and Vue booking widgets: typed public API client, date and timezone helpers, shared types, and the BookingWidget and ManageBooking components.","maintainers":[{"name":"viewfromthebox","email":"viewfromthebox94@gmail.com"}],"readme":"# ArtisanPack UI Bookings\n\nAppointment scheduling and booking management for Laravel — services, providers,\navailability, bookings, calendar sync, and a public booking widget.\n\n> **Status: released, v1.0.** The domain layer, HTTP surface, notifications,\n> calendar sync, GDPR tooling, and the Livewire and React/Vue frontends are all\n> in place and documented below.\n\n## Documentation\n\nThis README is the complete reference. The [documentation site][docs-home]\nexpands each topic into its own page with deeper worked examples — start there\nwhen you want more than the summary below.\n\n- **Getting started** — [Quick start][docs-getting-started]\n- **Usage** — [Creating a booking][docs-creating] · [Recurring bookings][docs-recurring] · [Public booking widget][docs-widget] · [Manage tokens][docs-manage-tokens] · [Self-serve page][docs-self-serve] · [iCal feeds][docs-ical] · [Admin surface][docs-admin]\n- **Notifications & webhooks** — [Email][docs-email] · [Admin email copies][docs-admin-emails] · [Reminders][docs-reminders] · [Text messages (SMS)][docs-sms] · [Outbound webhooks][docs-webhooks]\n- **Integrations** — [Calendar sync (two-way)][docs-calendar-sync] · [CMS framework][docs-cms] · [Forms][docs-forms] · [Media library][docs-media]\n- **Frontend** — [React][docs-react] · [Vue][docs-vue] · [Headless client][docs-headless]\n- **API reference** — [REST API][docs-rest] · [Services][docs-services] · [Models][docs-models] · [Events][docs-events] · [Hooks & filters][docs-hooks] · [Contracts][docs-contracts]\n- **Advanced** — [Artisan commands][docs-commands] · [GDPR & data retention][docs-gdpr] · [Multi-site][docs-multi-site] · [Performance][docs-performance] · [Rate limiting][docs-rate-limiting] · [Trusted proxies][docs-proxies] · [Webhook security][docs-webhook-security]\n\n[docs-home]: https://github.com/ArtisanPack-UI/bookings/wiki\n[docs-getting-started]: https://github.com/ArtisanPack-UI/bookings/wiki/Getting-Started\n[docs-creating]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Creating-Bookings\n[docs-recurring]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Recurring-Bookings\n[docs-widget]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Booking-Widget\n[docs-manage-tokens]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Manage-Tokens\n[docs-self-serve]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Self-Serve-Page\n[docs-ical]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Ical-Feeds\n[docs-admin]: https://github.com/ArtisanPack-UI/bookings/wiki/Usage-Admin-Surface\n[docs-email]: https://github.com/ArtisanPack-UI/bookings/wiki/Notifications-Email\n[docs-admin-emails]: https://github.com/ArtisanPack-UI/bookings/wiki/Notifications-Admin-Emails\n[docs-reminders]: https://github.com/ArtisanPack-UI/bookings/wiki/Notifications-Reminders\n[docs-sms]: https://github.com/ArtisanPack-UI/bookings/wiki/Notifications-Sms\n[docs-webhooks]: https://github.com/ArtisanPack-UI/bookings/wiki/Notifications-Webhooks\n[docs-calendar-sync]: https://github.com/ArtisanPack-UI/bookings/wiki/Integrations-Calendar-Sync\n[docs-cms]: https://github.com/ArtisanPack-UI/bookings/wiki/Integrations-Cms-Framework\n[docs-forms]: https://github.com/ArtisanPack-UI/bookings/wiki/Integrations-Forms\n[docs-media]: https://github.com/ArtisanPack-UI/bookings/wiki/Integrations-Media-Library\n[docs-react]: https://github.com/ArtisanPack-UI/bookings/wiki/Frontend-React\n[docs-vue]: https://github.com/ArtisanPack-UI/bookings/wiki/Frontend-Vue\n[docs-headless]: https://github.com/ArtisanPack-UI/bookings/wiki/Frontend-Headless-Client\n[docs-rest]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Rest-Api\n[docs-services]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Services\n[docs-models]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Models\n[docs-events]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Events\n[docs-hooks]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Hooks\n[docs-contracts]: https://github.com/ArtisanPack-UI/bookings/wiki/Api-Contracts\n[docs-commands]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Artisan-Commands\n[docs-gdpr]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Gdpr-Data-Retention\n[docs-multi-site]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Multi-Site\n[docs-performance]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Performance\n[docs-rate-limiting]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Rate-Limiting\n[docs-proxies]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Trusted-Proxies\n[docs-webhook-security]: https://github.com/ArtisanPack-UI/bookings/wiki/Advanced-Webhook-Security\n\n## Requirements\n\n- PHP 8.2+ (Laravel 13 itself requires PHP 8.3+)\n- Laravel 11, 12, or 13\n\nThe two constraints resolve together rather than conflicting: on PHP 8.2 Composer\ninstalls Laravel 12 or below, and Laravel 13 becomes available once the host\napplication is on PHP 8.3+.\n\n## Installation\n\n```bash\ncomposer require artisanpack-ui/bookings\n```\n\nThe service provider is auto-discovered. Publish the configuration when you want\nto change the defaults:\n\n```bash\nphp artisan vendor:publish --tag=bookings-config\n```\n\nThat writes `config/artisanpack/bookings.php`. Laravel's config loader walks\nnested directories under `config/` and prefixes the key with the directory name,\nso that file loads under `artisanpack.bookings` — the same key the package reads\nfrom. Individual settings are reached with dot notation:\n\n```php\nconfig( 'artisanpack.bookings.slot_interval' );        // 15\nconfig( 'artisanpack.bookings.admin.gate' );           // 'bookings.manage'\nconfig( 'artisanpack.bookings' );                      // the whole array\n```\n\nMigrations are loaded by the package, so `php artisan migrate` creates the\nsixteen booking tables with nothing else to do. Publish them only if you need to\nedit the schema:\n\n```bash\nphp artisan vendor:publish --tag=bookings-migrations\n```\n\nPublishing copies the files into `database/migrations` and leaves the package\nloading its own. That is not a conflict and does not run anything twice: the\nmigrator keys files by migration name, and the application's path is searched\nlast, so your copy shadows the package's and is the one that runs. Editing a\npublished migration works.\n\nWhat publishing does not do is freeze the set. A later release that adds a\n*new* migration has no counterpart in your directory to be shadowed by, so it\nloads from the package and runs on the next `migrate` alongside your edited\ncopies. Publish again after upgrading if you want the whole set under your own\ncontrol.\n\n## Configuration\n\nThe published file documents every key inline. The ones worth knowing up front:\n\n| Key | Default | What it controls |\n| --- | --- | --- |\n| `timezone` | `config( 'app.timezone' )` | Zone that unqualified availability is authored in |\n| `slot_interval` | `15` | Minutes between candidate slot start times |\n| `booking_window` | 60 min – 90 days | How soon and how far ahead a customer may book |\n| `cancellation` | allowed, 24h notice | Self-serve cancellation policy |\n| `calendar.drivers` | all disabled | Google / Microsoft / Apple sync, opt-in per driver |\n| `admin.route_prefix` | `bookings-admin` | Prefix for the staff-facing routes |\n| `public.route_prefix` | `bookings` | Prefix for the customer-facing routes |\n\nEnvironment variables cover the settings most likely to differ per environment:\n`BOOKING_DEFAULT_TIMEZONE`, `BOOKING_SLOT_INTERVAL`, `BOOKING_SMS_DRIVER`,\n`BOOKING_GOOGLE_ENABLED`, `BOOKING_MICROSOFT_ENABLED`, `BOOKING_APPLE_ENABLED`,\nand `BOOKING_PRUNE_DAYS`.\n\n## Rate limiting\n\nEvery public route is reachable without credentials, so each one carries a\nbucket. The limits are named rather than numeric — `bookings.rate-limit:post`\nrather than `throttle:5,1` — and live under\n`config( 'artisanpack.bookings.public.rate_limits' )`, so an installation raises\nthem for its own traffic in one place instead of at each route:\n\n| Bucket | Default (per minute) | Keyed by | Guards |\n| --- | --- | --- | --- |\n| `post` | 5 | address | `POST api/bookings`, the widget, and the self-serve cancel / reschedule |\n| `manage_get` | 20 | address | The manage page read |\n| `manage_token` | 60 | manage token | The manage read and the customer feed |\n| `ical` | 30 | address | The provider and customer calendar feeds |\n| `ical_token` | 30 | feed token | The provider calendar feed |\n\nThe reads that carry a link's whole credential are guarded twice — once per\naddress, once per token — because the two bound different abuses: a machine\ngrinding through guesses, and a link that has escaped into the world being\nfetched from everywhere at once.\n\n### Trusted proxies\n\nThe address-keyed buckets are only as truthful as `Request::ip()`. Behind a\nload balancer, a CDN, or any reverse proxy, an application that has not told\nLaravel which proxies to trust sees every request as coming from the proxy — so\n`Request::ip()` returns the proxy's own address (often `127.0.0.1`), every\ncustomer in the world shares the one `post` bucket, and the fifth booking of the\nminute is refused for all of them. Configure Laravel's trusted proxies before putting these routes in front\nof real traffic.\n\nIn Laravel 11, 12, and 13 that is done in `bootstrap/app.php`:\n\n```php\nuse Illuminate\\Foundation\\Configuration\\Middleware;\n\n->withMiddleware( function ( Middleware $middleware ): void {\n    $middleware->trustProxies( at: [\n        '192.0.2.10', // the load balancer or CDN address requests actually arrive from\n    ] );\n} )\n```\n\nTrust only the proxies you operate. `at: '*'` trusts whatever sets\n`X-Forwarded-For`, which hands every caller the ability to name their own\naddress and slip the per-address buckets — set it only when something you\ncontrol terminates every request before this application reaches it.\n\n## Multi-site\n\nSite scoping is configured once for the whole ecosystem, in\n`artisanpack.core.multi_tenant` — not in this package's config. Set\n`ARTISANPACK_MULTI_TENANT_ENABLED=true` (or the `enabled` key) to switch it on,\nand list resolvers under `artisanpack.core.multi_tenant.resolvers`. Every owned\ntable carries a nullable `site_id`, and models using\n`Models\\Concerns\\BelongsToSite` filter on whatever\n`ArtisanPackUI\\Core\\MultiTenancy\\SiteContext` reports — so a request cannot be\nsite 2 for one ArtisanPack package while being site 1 for this one.\n\nWork that has to target or span a specific site pins one explicitly, which is\nwhat a console command looping over sites needs:\n\n```php\nuse ArtisanPackUI\\Core\\Facades\\ArtisanPackSite;\n\nArtisanPackSite::forSite( $siteId, fn () => /* every bookings query answers for $siteId */ );\nArtisanPackSite::withoutSite( fn () => /* unscoped, for maintenance work */ );\n```\n\nEnabling scoping on an installation that already holds bookings needs `site_id`\nbackfilled first: rows written while it was off carry a null `site_id`, and the\nscope matches on equality, so they leave every site-scoped query the moment a\nsite resolves. `acrossAllSites()` still sees them.\n\nBackfill them before switching scoping on:\n\n```bash\n# Preview the counts per table without writing anything.\nphp artisan bookings:backfill-site-id --site=1 --dry-run\n\n# Stamp every pre-scoping row with the site they belong to.\nphp artisan bookings:backfill-site-id --site=1\n```\n\nThe command walks every table a site owns directly, spans every site, and\nreaches soft-deleted rows, so a booking already pruned for retention is\nbackfilled too. Run it once against the site the existing data belongs to, then\nset `ARTISANPACK_MULTI_TENANT_ENABLED=true`.\n\n## Usage\n\nThe package entry point resolves through the container, a facade, or a helper —\nall three return the same instance:\n\n```php\nuse ArtisanPackUI\\Bookings\\Facades\\Bookings;\n\napp( 'bookings' );\nBookings::getFacadeRoot();\nbookings();\n```\n\nThe booking, availability, and calendar APIs sit on top of this entry point and\nare covered in the sections that follow.\n\n### Creating a booking\n\n`Services\\BookingService` is the front door to a booking's whole life. Creating\none names a service, a start time, and the customer; naming a provider is\noptional, and leaving it out lets the service's assignment strategy pick:\n\n```php\nuse ArtisanPackUI\\Bookings\\Services\\BookingService;\n\n$booking = app( BookingService::class )->create( [\n    'service'           => $service,\n    'start_time'        => $start,          // any Carbon or parseable string\n    'customer_name'     => 'Sam Rivera',\n    'customer_email'    => 'sam@example.test',\n    'customer_timezone' => 'America/Chicago',\n    'intake_data'       => [ 'goal' => 'Learn to juggle' ],\n] );\n```\n\nThe whole read-availability-and-write sequence runs behind a lock on the\nprovider's local day, so two customers after the same provider are decided before\neither reaches the database. A day rather than a slot, because bookable slots\noverlap: at the default fifteen-minute interval a sixty-minute service offers one\nevery quarter hour, and per-slot locks would let 09:00 and 09:15 race through\nseparately and double-book the provider for forty-five minutes.\n\nPostgres and MySQL use the server's own advisory\nlocks, which hold across every process talking to that database. Every other\nengine — sqlite, chiefly — has no such primitive, so the cache store's lock\nstands in: that is exclusive within one application server and only as wide as\nthe cache store behind it, so point `artisanpack.bookings.lock.store` at a shared\none if you run more than one.\n\nEither way the lock is a first line of defence rather than the last. If a request\nstill loses the race, the partial unique index on `bookings` catches it and the\nround-robin assigner falls through to the next free provider. `create()` throws\n`Exceptions\\SlotUnavailableException` only when nobody at all could take the slot.\n\nIntake answers are validated against the service's current form and that version\nis snapshotted onto the booking, so the answers stay readable after an\nadministrator edits the form. Answers the form did not ask for are dropped;\nanswers it did ask for and did not get raise\n`Exceptions\\IntakeValidationException`, which carries a `MessageBag`.\n\nThe rest of the lifecycle goes through the same service, and must:\n\n```php\n$bookings = app( BookingService::class );\n\n$bookings->confirm( $booking, BookingActor::Admin );\n$bookings->reschedule( $booking, $newStart, BookingActor::Customer );\n$bookings->cancel( $booking, BookingActor::Customer, 'Something came up.' );\n$bookings->complete( $booking, BookingActor::Provider );\n$bookings->markNoShow( $booking, BookingActor::Admin );\n```\n\nFlipping a status directly would skip the action and the event that transition\nfires, so anything downstream — a calendar push, a confirmation email, a CRM\nrecord — would either never hear about it or hear about it twice.\n\n### Recurring bookings\n\n`Services\\SeriesService` books a repeating arrangement. It takes the booking\nattributes above plus an RFC 5545 recurrence rule and a **floating** start — a\nclock face and the zone to read it in, not an instant:\n\n```php\nuse ArtisanPackUI\\Bookings\\Services\\SeriesService;\n\n$series = app( SeriesService::class )->create( [\n    'service'          => $service,\n    'rrule'            => 'FREQ=WEEKLY;COUNT=12',\n    'dtstart_local'    => '2026-06-01 15:00:00',\n    'dtstart_timezone' => 'America/Chicago',\n    'customer_name'    => 'Sam Rivera',\n    'customer_email'   => 'sam@example.test',\n] );\n\n$series->occurrences;   // twelve ordinary bookings, linked by series_id\n```\n\nThe rule is the source of truth and the occurrences are materialised from it —\nordinary bookings written one at a time through `BookingService`, so each takes\nthe slot lock, validates its intake answers, and fires the usual lifecycle hooks.\nStoring the start as a clock face rather than an instant is what makes a weekly\n15:00 call stay at 15:00 across a daylight-saving change instead of drifting to\n14:00 or 16:00.\n\nAn occurrence whose slot has gone is skipped rather than fatal — a rule expanded\nover months will cross somebody's holiday sooner or later — so compare\n`SeriesCreated::$occurrenceCount` against `expand()` if you need to tell the\ncustomer which weeks did not land. A rule where *nothing* could be booked throws\n`SlotUnavailableException` and leaves no series behind. Expansion is capped by\n`artisanpack.bookings.series.max_occurrences`, which is what stops an unbounded\n`FREQ=DAILY` from asking for an unbounded number of rows. A series is pinned to\none provider: recurring means the same person, so the first occurrence's\nassignment is written back onto the series and the rest follow it.\n\nEdits take a scope, which is the choice every calendar application offers:\n\n```php\nuse ArtisanPackUI\\Bookings\\Enums\\SeriesEditScope;\n\n$recurring = app( SeriesService::class );\n\n// One week moves; the rule is untouched and that occurrence stops following it.\n$recurring->edit( $series, SeriesEditScope::This, [ 'start_time' => $newStart ], $occurrence );\n\n// The rule is bounded here, and the new series it returns carries the change forward.\n$tail = $recurring->edit( $series, SeriesEditScope::ThisAndFollowing, [ 'rrule' => '…' ], $occurrence );\n\n// The rule is rewritten and everything still to come is re-derived from it.\n$recurring->edit( $series, SeriesEditScope::All, [ 'rrule' => '…' ] );\n\n$recurring->cancel( $series, BookingActor::Customer, 'Moving away.' );\n```\n\nOccurrences that have already started are never rewritten — they happened — and\nneither are detached ones, since detaching is the record that somebody edited that\nweek by hand. `cancel()` is the exception: it calls off every future occurrence\nincluding the detached, because moving one week to a different afternoon does not\nmake it less part of the arrangement being cancelled.\n\nBoth rewriting scopes free the old slots before taking the new ones, which is the\nonly order that lets a rule keep times it already holds. A rule that cannot be\n*read* is refused before anything is cancelled, so a typo in the RRULE leaves the\narrangement standing; a rule that reads fine but books nothing throws\n`SlotUnavailableException` after the fact, because the alternative is returning\nan empty arrangement the caller would take for a working one. Individual weeks\nthat cannot be booked are still skipped rather than fatal — it is only losing\n*all* of them that is treated as failure.\n\nA `this_and_following` split divides a `COUNT` between the two halves rather than\ngiving it to both: splitting `FREQ=WEEKLY;COUNT=12` at week five leaves the head\nwith four and the tail with eight, not twelve. Supply your own `rrule` in the\nchanges to override that — a caller writing a new rule is redefining the\narrangement, not continuing it. Splitting at the very first occurrence cancels\nthe head instead of bounding it, since there is nothing before the split to keep.\n\nEditing a cancelled series throws: an admin with the form open when the customer\ncancels would otherwise resurrect it, and the provider would get appointments for\nan arrangement every screen reports as off.\n\nEdits run pinned to the series' own site, not to whichever site happens to be in\ncontext. That matters for the two ways the package supports crossing sites — a\nconsole command using `SiteContext::forSite()` and a maintenance query using\n`acrossAllSites()` — where the ambient site otherwise disagrees with the series\nbeing edited.\n\n### Public booking widget\n\nDrop the widget on any page:\n\n```blade\n<livewire:artisanpack-booking-widget />\n```\n\nIt walks the customer through service → provider → date and time → details, and\nconfirms the booking in place. Pin it to one service when the page is already\nabout that service, and give it a zone to render times in before the browser has\nreported its own:\n\n```blade\n<livewire:artisanpack-booking-widget service=\"discovery-call\" />\n<livewire:artisanpack-booking-widget timezone=\"Europe/Berlin\" />\n```\n\nA pinned service is locked: the widget will not book anything else, whatever the\npage's query string or a modified client asks for.\n\nThe component is registered only when `livewire/livewire` is installed. It is a\nsuggestion rather than a requirement — the JSON API and the iCal feeds are the\nwhole surface a headless installation needs — so `composer require\nlivewire/livewire` if you want the widget.\n\n**The flow works without JavaScript.** Every step is a real `<form>`: choosing a\nservice, a provider, a month, a day, or a time is a `GET` back to the same page\ncarrying the choice in the query string, and confirming is a `POST` to\n`bookings/widget`, which creates the booking and redirects back with the\nconfirmation flashed. Where Livewire has loaded it intercepts all of that and\nnothing navigates. What JavaScript adds is the one thing the server cannot know:\nthe visitor's timezone, read from\n`Intl.DateTimeFormat().resolvedOptions().timeZone`. Without it the times are\nshown in the service's own zone, and the widget says so on screen rather than\nleaving it to be guessed.\n\nThat `POST` route sits in the `web` middleware group — it needs the session and\nthe CSRF token the JSON API deliberately does without — and redirects to the\nsession's previous URL rather than to anything in the payload. A host that never\nrenders this widget — one routing bookings through its own forms — stops that\nroute registering with `public.widgetEnabled` (default `true`); only the widget's\nsession-backed form target goes, and the JSON API, the iCal feeds, and the manage\nendpoints on the same public surface carry on unchanged.\n\nBecause the state lives in the query string, a link is shareable and\ndeep-linkable:\n\n```text\nhttps://example.test/book?bookingService=discovery-call&bookingDate=2026-06-01\n```\n\nThe intake step renders the service's current `intake_schema` — the same field\nlist, in the same order, with the same idea of \"required\" that\n`Services\\IntakeFieldValidator` will judge the answers against, so the form\ncannot ask for something the check does not want or omit something it does.\n\nThe markup is plain HTML with daisyUI class names and no dependency on\n`artisanpack-ui/livewire-ui-components`. Publish it to change anything:\n\n```bash\nphp artisan vendor:publish --tag=bookings-views\n```\n\nBoth halves of the widget spend the same `public.rate_limits.post` bucket as\n`POST api/bookings`, so a visitor gets one allowance rather than one per route.\n\n### Manage tokens\n\nA customer with no account manages their booking through a link, and the token in\nthat link is their whole credential. `Services\\ManageTokenService` is the one\nplace one is minted, hashed, and checked:\n\n```php\nuse ArtisanPackUI\\Bookings\\Services\\ManageTokenService;\n\n$tokens = app( ManageTokenService::class );\n\n// Minted automatically when a booking is created — take it once, for the email.\n$token = $booking->pullPlainManageToken();\n\n$booking = $tokens->findBooking( $requestToken );   // null when the token is unknown\n$tokens->verifyFor( $booking, $requestToken );      // hash_equals, never ==\n\n$fresh = $tokens->issueFor( $booking );             // the old link stops working here\n```\n\nThe token is 32 bytes of CSPRNG output rendered as 64 hex characters, and nothing\nabout it is derived from the booking — a token that encoded the reference or the\ncustomer's email would let somebody who knows one booking enumerate the rest.\n`bookings.manage_token_hash` stores `sha256(token)` and nothing else, so a leaked\nrow hands over a hash that cannot be turned back into a working link. There is\ndeliberately no way to recover a plain token from a saved booking: it is returned\nonce, to whoever minted it. A customer who loses the link gets a new one issued.\n\nThree endpoints are mounted behind that token, and the `bookings.manage-token`\nmiddleware in front of them is the whole authentication layer:\n\n```text\nGET  api/bookings/manage/{token}\nPOST api/bookings/manage/{token}/cancel        { \"reason\": \"optional\" }\nPOST api/bookings/manage/{token}/reschedule    { \"start_time\": \"2026-06-01T19:00:00+00:00\" }\n```\n\nAn unknown token, a malformed one, and a token belonging to another site all\nanswer with the same 404 and the same message — anything more specific tells a\nguesser which guesses were closer. Reads are limited per address *and* per token\n(`public.rate_limits.manage_get` and `manage_token`); writes share the `post`\nbucket.\n\nBoth writes go through `BookingService`, so `ap.bookings.cancelled` and\n`ap.bookings.rescheduled` fire with `actor: customer`, and the notifications,\nwebhooks, and calendar sync hanging off them behave as they do everywhere else.\n`cancellation.allowed` and `cancellation.min_advance_minutes` govern what the link\nmay still do; the read reports that as `meta`, which is what a widget should draw\nits buttons from rather than working the policy out again:\n\n```json\n{\n    \"data\": { \"id\": 41, \"status\": \"confirmed\", \"start_time\": \"2026-06-01T15:00:00+00:00\" },\n    \"meta\": { \"can_cancel\": true, \"can_reschedule\": true, \"changes_allowed_until\": \"2026-05-31T15:00:00+00:00\" }\n}\n```\n\nA reschedule is checked against availability as it stands now rather than against\nthe slot list the page was drawn from, and answers 409 when somebody else took the\nslot first.\n\nWhen a token has leaked — a forwarded confirmation, a mail archive, a referrer\nheader — rotate every one of them:\n\n```bash\nphp artisan bookings:reissue-detached-manage-tokens\n```\n\nIt is deliberately blunt: every token in every site is replaced, so every manage\nlink the package has ever sent stops working, and a customer has no way back in\nuntil somebody sends them a new one. `ap.bookings.manageTokenReissued` fires per\nbooking with the new plain token — the only moment it can be read — so an\napplication with mail wired up can re-send as the rotation runs. Pass `--force`\nto skip the confirmation prompt and `--chunk` to tune the query size.\n\n### Self-serve management page\n\nThose endpoints are the machine-facing half of the manage link, and a customer\nshould not have to be one. Mount the page on a route carrying the\n`bookings.manage-token` middleware and drop the component on it:\n\n```php\nuse Illuminate\\Support\\Facades\\Route;\n\nRoute::get( '/bookings/manage/{token}', fn () => view( 'bookings.manage' ) )\n    ->middleware( [\n        'bookings.rate-limit:manage_get',\n        'bookings.rate-limit:manage_token',\n        'bookings.manage-token',\n    ] )\n    ->name( 'bookings.manage' );\n```\n\nGive it the same two limiters `GET api/bookings/manage/{token}` carries, in that\norder — they bound different abuses, one per address and one per token, and the\nresolver is declared last so a guess is counted before it costs a lookup. A page\nmounted behind the resolver alone is a weaker door onto the same booking than the\nendpoint it replaces.\n\n```blade\n<livewire:artisanpack-manage-booking />\n```\n\nIt shows the appointment, cancels it behind a confirmation step with an optional\nreason, and moves it to another slot on the same service and provider. The token\nis read from the route — pass it explicitly with `:token=\"$token\"` where the\nroute names it something else — and is `#[Locked]`, so a modified client cannot\npoint the page at a booking whose token it has guessed.\n\nThe booking is re-resolved from that token on every request rather than held\nacross them, so a page left open on a phone reflects a cancellation made from\nanywhere else instead of acting on a copy from before it. Which buttons are drawn\ncomes from the same policy the endpoints enforce — `cancellation.allowed`,\n`cancellation.min_advance_minutes`, and whether the service is still active — so\nthe page never offers something the write behind it would refuse. A withdrawn\nservice stops the reschedule and leaves the cancel, which is the part a customer\nwhose service has been retired most needs.\n\nBoth writes go through `BookingService` with `actor: customer`, exactly as the\nendpoints do, and the slots on offer are resolved through `SlotResolver` — so\n`ap.bookings.availableSlots` and `ap.bookings.slotBookable` decide what a customer\nmay pick here too, and a slot a subscriber removed cannot be booked by sending its\ninstant anyway. Both actions spend the same `public.rate_limits.post` bucket as\n`POST api/bookings`.\n\nOne thing to know before mounting it anywhere unusual: Livewire serialises a\ncomponent's public properties into the page, so **the plain manage token is in the\nrendered markup and in every update payload, whichever way it was passed in**. On\nthe route above that is the same secret in two places on one page. Passing it as\n`:token=\"$token\"` — from a POST body, a session value, anywhere but the URL —\nkeeps it out of the *address*, and does not keep it out of the *response*: there\nis no mounting style that does. Weigh that where something records the DOM, since\nsession replay and error reporters capture markup that referrer policies and\nURL-stripping rules never touch.\n\nLike the widget, the markup is plain HTML with daisyUI class names and publishes\nwith `php artisan vendor:publish --tag=bookings-views`. Unlike the widget, it\nneeds Livewire: its writes have no plain-form route to post to, and the JSON\nendpoints above are what a bespoke page should be built on instead.\n\n### iCal feeds\n\nTwo subscribable calendars, so a provider can watch their diary from Apple\nCalendar, Google Calendar, or Outlook without anybody connecting an account:\n\n```text\nGET bookings/ical/providers/{token}.ics     # a provider's diary\nGET bookings/ical/customers/{token}.ics     # the booking a manage token stands for\n```\n\nBoth sit under `public.route_prefix` without the `api/` the JSON endpoints carry:\nnothing calls them from a widget, so the address is one somebody has to be able to\npaste into a subscription box. The `.ics` is part of the path rather than a query\nstring, because clients guess how to treat a subscription URL from its extension.\n\nThey are built to be polled. Google refetches a subscribed feed roughly hourly,\nApple about every fifteen minutes, forever, once per subscriber — so every request\ncomputes an entity tag from one aggregate and answers `304 Not Modified` before a\nsingle booking is read:\n\n```text\nETag: \"7f3c…\"\nCache-Control: private, max-age=300\n```\n\nSend it back as `If-None-Match` and the answer is an empty 304. Weak tags and\nmulti-tag lists are handled, so a proxy in the way does not turn every poll into a\nfull fetch. The tag folds in the newest `updated_at` in the window, how many\nbookings are in it, and how many of those are still published — the counts are\nwhat make a cancellation move the tag, since a booking going away lowers the\nnewest timestamp rather than raising it.\n\nA feed carries the recent past and the booked future rather than the archive.\n`public.ical.past_days` (30) and `future_days` (365) set the window, and\n`max_age` (300) says how long a client may hold the answer.\n\n#### The provider feed token\n\nA provider feed is addressed by a token issued to that provider and to nobody\nelse. It has to be: the feed carries the customer's name and email, and the only\nother thing that could address it is the provider's slug — which\n`GET api/bookings/services/{slug}/providers` publishes, making the URL something\nanybody who can read the booking widget could construct.\n\nNothing mints a token automatically. A provider has no feed until somebody asks\nfor one, and a provider without one 404s:\n\n```bash\nphp artisan bookings:ical-token ada-lovelace     # by slug, or by id\nphp artisan bookings:ical-token ada-lovelace --revoke\n```\n\nThe URL it prints is shown once and cannot be recovered. Only `sha256(token)` is\nstored, in `service_providers.ical_token_hash`, the way `bookings.manage_token_hash`\nholds a manage token — so a leaked backup or a read-only replica hands over\nsomething that cannot be turned back into a working subscription.\n\nOne URL, any number of devices: the same address can be pasted into a phone, a\nlaptop, and a desktop client, and they all keep working. What cannot be done is\n*look it up again* — so the URL is worth keeping somewhere the provider can reach\nit, not just pasting once.\n\nRotate when the URL has been lost or you think it has been seen by somebody it\nshould not have been. **Rotation is not free**: running the command again for a\nprovider who already has a feed replaces the token, and every calendar client\nsubscribed to the old URL stops updating at that moment. It does so silently,\nbecause a subscribed feed that starts 404ing does not announce itself — so after\na rotation every one of that provider's clients has to be given the new URL. The\ncommand warns and asks before it does this; `--force` skips the prompt.\n\n`ap.bookings.icalTokenIssued` fires with the new plain token — the only moment it\nis readable — so an application with mail wired up can deliver the subscription\nURL itself:\n\n```php\naddAction( 'ap.bookings.icalTokenIssued', function ( ServiceProvider $provider, string $token ) {\n    Mail::to( $provider->email )->send( new CalendarFeedIssued(\n        app( IcalTokenService::class )->feedUrl( $token ),\n    ) );\n} );\n```\n\nFeed lookups go through `Services\\IcalTokenService`, which shares its primitives\nwith `ManageTokenService` — 32 CSPRNG bytes as 64 hex characters, `sha256` in the\ncolumn, `hash_equals()` on the way back — so the two credential schemes cannot\ndrift apart. Unknown tokens, revoked feeds, rotated tokens, deactivated providers,\nand tokens belonging to another site all answer with the same 404 and the same\nmessage.\n\nThe customer feed is the manage token's, guarded by the same\n`bookings.manage-token` middleware as the manage endpoints, and carries the one\nbooking that token stands for — a token discloses no more through its calendar\nthan through the link it came in on. It is limited per address *and* per token,\nfor the reason the manage read is: a feed URL sits in a calendar client's settings\nfor years, which is exactly how a link escapes. The provider feed is limited the\nsame way, per address and per token (`public.rate_limits.ical` and `ical_token`).\n\nRescheduling moves an event a subscriber already has rather than leaving a\nduplicate behind: the `UID` is built from `booking_number`, which never changes.\nCancelling removes it, which is what a provider wants their week to look like.\n\n### Outbound webhooks\n\nSubscribe an endpoint and the booking lifecycle fans out to it on its own — no\nwiring, no listener of your own:\n\n```php\nuse ArtisanPackUI\\Bookings\\Models\\Webhook;\n\nWebhook::create( [\n    'name'   => 'Zapier',\n    'url'    => 'https://hooks.zapier.test/bookings',\n    'secret' => Str::random( 40 ),\n    'events' => [ 'booking.confirmed', 'booking.cancelled' ],\n] );\n```\n\nSeven events are raised, one per lifecycle transition:\n\n| Event                 | Raised when                                             |\n|-----------------------|---------------------------------------------------------|\n| `booking.created`     | A booking is made, whether or not it is yet confirmed    |\n| `booking.confirmed`   | It becomes an appointment                                |\n| `booking.rescheduled` | It moves; carries `data.previous_period`                 |\n| `booking.reassigned`  | It moves to another provider; carries `data.previous_provider_id` |\n| `booking.cancelled`   | It is called off; carries `data.reason`                  |\n| `booking.completed`   | It is marked as having happened                          |\n| `booking.no_show`     | The customer did not arrive                              |\n\nA booking that is auto-confirmed raises `booking.created` and\n`booking.confirmed` in that order, which is the pair a consumer should expect.\n\nEach delivery is queued as its own job and recorded in\n`booking_webhook_deliveries`, so what was sent, what came back, and whether it\nwill be tried again are questions the database answers. The body is an envelope\naround the booking:\n\n```json\n{\n  \"event\": \"booking.confirmed\",\n  \"occurred_at\": \"2026-08-10T20:41:56+00:00\",\n  \"data\": {\n    \"booking\": {\n      \"id\": 4711,\n      \"booking_number\": \"BK-8F2A1C\",\n      \"status\": \"confirmed\",\n      \"start_time\": \"2026-08-14T13:00:00+00:00\",\n      \"end_time\": \"2026-08-14T13:30:00+00:00\",\n      \"customer\": { \"name\": \"Dana Scully\", \"email\": \"dana@example.test\", \"timezone\": \"Pacific/Auckland\" },\n      \"service\": { \"id\": 3, \"name\": \"Consultation\", \"slug\": \"consultation\" },\n      \"provider\": { \"id\": 8, \"name\": \"Alex Kim\", \"slug\": \"alex-kim\" }\n    },\n    \"actor\": \"customer\"\n  }\n}\n```\n\nTimes are UTC, with the customer's own zone named beside them rather than\napplied to them — a consumer wanting to show a clock face has what it needs, and\none wanting an instant is not made to guess which it was given.\n\nRaise your own events through the same machinery when you have something to say\nthat the lifecycle does not cover:\n\n```php\nuse ArtisanPackUI\\Bookings\\Services\\WebhookDispatcher;\n\napp( WebhookDispatcher::class )->dispatch( 'booking.confirmed', $payload, $siteId );\n```\n\nPass the site when you know it. Left out, the endpoint list is scoped to\nwhatever site is in context — which in console is none of them, and therefore\nall of them.\n\nEach request carries the event, the delivery id, the attempt number, a\ntimestamp, and a signature:\n\n```\nX-ArtisanPack-Event: booking.confirmed\nX-ArtisanPack-Delivery: 4711\nX-ArtisanPack-Attempt: 1\nX-ArtisanPack-Timestamp: 1767024000\nX-ArtisanPack-Signature: sha256=<hex>\n```\n\nThe signature is `HMAC-SHA256( \"{timestamp}.{body}\", secret )` over the exact\nbytes of the request body. Verify it in constant time, and reject a timestamp\nolder than your own tolerance — that is what stops a captured request from being\nreplayed, and it is why the timestamp is inside the signed string rather than\nmerely beside it:\n\n```php\n$signed = $request->header( 'X-ArtisanPack-Timestamp' ) . '.' . $request->getContent();\n\nif ( ! hash_equals( 'sha256=' . hash_hmac( 'sha256', $signed, $secret ), $request->header( 'X-ArtisanPack-Signature' ) ) ) {\n    abort( 401 );\n}\n```\n\nA delivery is accepted on any 2xx. Anything else — a 500, a refused connection, a\ntimeout — is a failure, and the delivery is retried on\n`webhooks.delivery_backoff_minutes`, which defaults to 1, 5, 30, 120, and 720\nminutes. That is the list of delays *between* attempts, so an endpoint gets six\nattempts over about fourteen hours before the delivery is marked `dead` and left\nalone.\n\nFailures are counted on the endpoint rather than on the delivery, so an endpoint\nreturning 500 to everything is disabled after `webhooks.failure_threshold`\nconsecutive failures however those were spread across events — and a single\nsuccess resets the count, because the threshold is for an endpoint that is gone\nrather than one that flapped. Disabling fires `WebhookDisabled`, which is where\nan application tells the consumer their integration has stopped; nothing else\nwill.\n\nDeliveries are pushed onto the default queue unless `webhooks.queue` names one.\nGive them their own queue on an installation where a slow consumer must not\ndelay everything else that is queued.\n\n**The endpoint URL is guarded against SSRF.** The package posts wherever\n`booking_webhooks.url` says, from inside your application, and stores the first\n2,000 characters of the reply where an admin screen can read it. That is a\nrequest-forgery primitive if the URL is not trusted: an internal service or a\ncloud metadata endpoint is reachable from your server and not from a browser,\nand the response comes back out through the delivery ledger. Redirects are\nrefused, so the address that was reviewed stays the address that is called.\n\nThe address itself is checked by a guard, `webhooks.url_guard`, on by default. It\nallows only the schemes on `allowed_schemes` (`https` alone unless you add\n`http`) and refuses any URL whose host resolves to a loopback, private,\nlink-local, or unique-local address — every address a name answers with, since a\nname offering one public and one private address is still a way into the private\none. The check runs at delivery, not only when the endpoint is saved, because a\nname approved once can be repointed afterwards; a refused URL kills the delivery\nthe way a disabled endpoint does, with the reason on the row. The host is\nresolved once and the vetted address is pinned into the connection, so the HTTP\nclient cannot resolve the name a second time and reach an address the guard\nnever checked — the DNS-rebinding case. The request is made under the host the\naddress was pinned to (lower-cased, no trailing dot, and IDN names in their\npunycode form) so the client resolves the exact string the pin was keyed on. TLS\nstill verifies against the hostname.\n\nPinning is enforced by `CURLOPT_RESOLVE`, which needs the curl HTTP handler\n(`ext-curl`) and libcurl 7.59 or newer for its multi-address form. An\ninstallation without curl, or on older libcurl, still gets the delivery-time\naddress check — it just cannot pin the connection to it, so a determined\nDNS-rebinding attacker regains the narrow resolve-then-connect window there.\n\nAn installation that delivers to an internal host on purpose names it in\n`allowed_hosts`, where it skips the range check; `blocked_hosts` refuses a host\nwhatever it resolves to; and `enabled => false` switches the guard off for an\ninstallation where every endpoint is operator-created. If you accept an endpoint\nURL below operator trust — a tenant self-service screen — apply the\n`ValidWebhookUrl` rule to the field so a bad address is refused before it is\nstored:\n\n```php\nuse ArtisanPackUI\\Bookings\\Rules\\ValidWebhookUrl;\n\n$request->validate( [\n    'url' => [ 'required', 'url', new ValidWebhookUrl() ],\n] );\n```\n\n### Text messages\n\nAn `sms` notification channel ships, and sends nothing until you give it a\ngateway. Two switches, deliberately separate:\n\n```php\n// config/artisanpack/bookings.php\n'notifications' => [\n    'channels'   => [ 'mail', 'database', 'webhook', 'sms' ],\n    'sms_driver' => App\\Sms\\TwilioSmsDriver::class,\n],\n```\n\n`sms_driver` decides *how* a text is sent and `channels` decides *whether* one\nis. The default driver — `null` — logs the message at info level and sends\nnothing, so an installation that lists the channel before it has a gateway can\nsee exactly what would have gone out, and to which number, without paying for it.\n\n`sms` is not in the shipped channel list, and installing a driver does not add\nit. Texts cost money per message and arrive on a real phone, so sending one is\nnever something the package decides on your behalf.\n\nWriting a driver is one method. It is handed a number and a string, and knows\nnothing about bookings:\n\n```php\nuse ArtisanPackUI\\Bookings\\Contracts\\SmsDriver;\n\nclass TwilioSmsDriver implements SmsDriver\n{\n    public function send( string $phone, string $message ): void\n    {\n        // Throw if the gateway refuses it. The send is recorded against\n        // booking_notification_log as failed, the other channels carry on,\n        // and an operator has something to read.\n    }\n}\n```\n\nName the class in `sms_driver`, or bind `SmsDriver` in the container if it needs\nconstructing your way. A name that resolves to nothing throws rather than falling\nback to the null driver — an installation that thinks it configured Twilio and is\nquietly writing log lines has every customer unreachable and nothing obviously\nwrong.\n\nTo text on some events only — or only customers who asked to be texted — leave\n`sms` out of the configured list and add it from the channels filter:\n\n```php\naddFilter(\n    'ap.bookings.notification.channels',\n    function ( array $channels, string $event, Booking $booking ): array {\n        if ( 'cancellation' === $event ) {\n            $channels[] = 'sms';\n        }\n\n        return $channels;\n    },\n);\n```\n\nThe message body is the email's opening line and appointment details, without the\ngreeting. Replace it by returning your own notification with a `toSms(): string`\nmethod from `ap.bookings.notification.sending`. The channel declines a booking\nwith no phone number, and one whose personal data has been erased.\n\n**The null driver writes the number and the message to your log.** That is what\nit is for, and it is also customer contact details and an appointment time\nsitting in a file that erasing a booking does not reach — the erasure routine\nscrubs the booking and its related rows (see [GDPR & Erasure](Advanced-Gdpr-Data-Retention)), never `storage/logs`. Fine in\ndevelopment, and a disclosure you have to be able to make if you leave `sms`\nenabled without a gateway in production. Bind a driver that discards the body, or\ntake `sms` back out of the channel list, if you cannot.\n\n**A phone number on a booking is attacker-supplied.** If your widget is public,\nanyone can submit a number they do not own and make your application text it, at\nyour expense — the abuse is called SMS pumping, and a booking form that sends on\nrequest is the shape of it. Before you bind a real gateway to a public widget:\nrate-limit bookings per address and per source, hold texts until a booking is\nconfirmed rather than requested, and reject numbers outside the regions you\nserve. The package cannot do any of that for you, because only you know which\nnumbers are legitimate.\n\nTwilio and Vonage drivers ship in v1.1.\n\n### Scheduled commands\n\nThe package puts its own recurring work on your application's schedule. You do\nnot register anything — run Laravel's scheduler and it happens:\n\n```bash\nphp artisan schedule:work    # or the usual cron entry for schedule:run\n```\n\n| Command | Cadence | What it does |\n|---------|---------|--------------|\n| `bookings:send-reminders` | Every 15 minutes | Sends the reminders that have come due. |\n| `bookings:complete-past` | Hourly | Marks confirmed bookings whose end time has passed as completed. |\n| `bookings:calendar-refresh` | Daily | Re-reads busy blocks for two-way connections. |\n| `bookings:calendar-watch-renew` | Hourly | Renews Google/Microsoft push registrations before they lapse. |\n| `bookings:calendar-apple-poll` | Every 15 minutes | Polls Apple calendars, which cannot push. |\n| `bookings:prune` | Daily, 03:00 | Soft-deletes bookings past their retention window, keeping the row and its personal data. |\n| `bookings:prune-notification-log` | Daily, 03:10 | Removes notification log rows past their retention window. |\n| `bookings:prune-webhook-deliveries` | Daily, 03:20 | Removes settled delivery attempts past their retention window. |\n| `bookings:prune-calendar-events` | Daily, 03:30 | Removes calendar mappings for bookings long over. |\n\n`bookings:erase`, `bookings:reissue-detached-manage-tokens`, and\n`bookings:ical-token` are never scheduled. The first scrubs the personal data on\na booking, or on every booking for an email address, in answer to a right-to-erasure\nrequest; the second invalidates every manage link the package has ever sent; the\nthird invalidates one provider's calendar subscriptions and prints a URL that\nexists nowhere else. All three are things you do in response to something, in\nfront of the output, rather than things a clock should decide.\n\n```bash\n# Retention prune runs on the schedule, but you can run it by hand too.\nphp artisan bookings:prune --dry-run\n\n# Erasure is request-driven. Scrub one booking, or every booking for an address.\nphp artisan bookings:erase --booking=BK-7F3A9C\nphp artisan bookings:erase --email=customer@example.com\n```\n\n`bookings:prune` soft-deletes — the row and its personal data stay, for the legal\nor accounting record, and only drop out of the default queries. `bookings:erase`\nis the other obligation: it overwrites the personal columns in place and marks the\nrow erased, so aggregate reporting keeps working on a row that no longer names\nanyone. Erasure reaches soft-deleted bookings too, so a booking already pruned for\nretention is still reachable by the request to scrub it.\n\nEvery one is registered `withoutOverlapping()`. None of them needs it for\ncorrectness — a reminder is claimed in the notification log before it is sent,\ncompletion re-reads the status it transitions, and deleting rows another prune\nalready deleted is a no-op — but a doubled run is a lot of database round trips\nto do nothing.\n\nThe reminder sweep is dropped when `notifications.reminder.enabled` is false, and\nthe three calendar sweeps are registered only when a matching driver is switched\non under `calendar.drivers`. Both are gates on the schedule, not on the commands:\nyou can always run any of them by hand.\n\n**Everything destructive takes `--dry-run`**, which reports what a run would do\nand changes nothing. On `bookings:complete-past` that includes firing no hooks\nand dispatching no events, so a subscriber cannot email a customer about a\ncompletion that has not happened:\n\n```bash\nphp artisan bookings:complete-past --dry-run\nphp artisan bookings:prune-notification-log --dry-run\n```\n\nOnly `confirmed` bookings are completed. A `requested` one is an appointment\nnobody approved, and marking it delivered would be the package asserting that\nsomething happened which may never have been accepted — those are left for staff\nto dispose of, as a completion or a no-show.\n\nThe four prunes read their windows from `retention`:\n\n```php\n'retention' => [\n    'prune_after_days'         => 365 * 3,   // read by bookings:prune\n    'notification_log_days'    => 90,\n    'webhook_delivery_days'    => null,   // falls back to webhooks.delivery_retention_days\n    'calendar_events_ttl_days' => 30,\n],\n```\n\nA window of zero or less is read as \"not configured\" and prunes nothing, rather\nthan as \"keep nothing\" — a blank environment variable is a likelier way to reach\nzero than a retention policy is. That holds for `webhook_delivery_days` too: only\nleaving it unset defers to `webhooks.delivery_retention_days`, so zeroing it\nswitches the prune off rather than falling back to somebody else's thirty days.\nKeep `notification_log_days` comfortably longer\nthan your longest reminder in `hours_before`: the log row is what stops a\nreminder being sent twice, so pruning one inside its own reminder window\nun-claims a send that already happened. A `pending` webhook delivery is never\npruned however old it is, because deleting it makes the delivery stop existing\nrather than fail. And calendar mappings are pruned by the booking's end time\nrather than by the row's own age — a mapping for an appointment a year out is\nolder than yesterday's, and is exactly what a reschedule still needs.\n\n`prune_after_days` is measured the same way — from the booking's end time, not\nthe row's age — so a booking taken well ahead of time is not counted old the day\nit is made. The default is three years; set it to whatever your own retention\npolicy requires. It defaults from `BOOKING_PRUNE_DAYS`, and zeroing it (a blank\nenvironment variable, most often) switches the prune off rather than deleting\neverything.\n\nThe read-only iCal feed driver and the Google Calendar driver are implemented in\nthis package (the Google driver runs on OAuth gated behind `artisanpack-ui/google`).\nMicrosoft and Apple are recognised by the `CalendarDriver` enum but delivered by\n`artisanpack-ui/microsoft` and `artisanpack-ui/apple`. `bookings:calendar-refresh`\nroutes each due two-way connection to its driver and runs the driver's own\nread-back; a connection whose driver is not installed has nothing to sync it and\nthe sweep says so. The push-channel renewal (`bookings:calendar-watch-renew`)\nbelongs to the driver package that owns the callback URL, which it does by\nsubscribing to `ap.bookings.calendarSync.renewChannels`.\n\n### Admin surface\n\nThe staff-facing screens are mounted as routes under `admin.route_prefix`\n(`bookings-admin/…` by default), one per screen — the bookings list and\ncalendar, services and their intake schemas, providers and their schedules,\nblackout dates, recurring series, calendar connections, webhooks, the\nnotification log, and settings. They are named `artisanpack.bookings.admin.*`, so\nlink to them with `route()` rather than a hard-coded path:\n\n```php\nroute( 'artisanpack.bookings.admin.bookings' );   // the list\nroute( 'artisanpack.bookings.admin.settings' );   // general config\n```\n\nEvery screen sits behind the `bookings.admin` gate, which authorizes against the\nability named by `admin.gate` (`bookings.manage`). The package defines no default\nability on purpose: `Gate::authorize()` against an undefined ability denies, so\nmounting the admin without wiring the gate is a locked door, not an open one.\nDefine it against whatever \"staff\" means to your application:\n\n```php\nGate::define( 'bookings.manage', fn ( User $user ) => $user->isStaff() );\n```\n\nEach screen renders inside a layout chosen for you: `cms::admin.layouts.app` when\n`artisanpack-ui/cms-framework` is installed, and the package's own\n`bookings::admin.layouts.app` when it is not. Publish the standalone layout with\n`php artisan vendor:publish --tag=bookings-views` and edit the copy under\n`resources/views/vendor/bookings/admin/layouts/app.blade.php` to wrap the screens\nin your own chrome.\n\nWith cms-framework installed, the screens also register themselves in its admin\nnavigation through the `ap.cmsFramework.admin.menu` filter — a single **Bookings**\nsection with every screen beneath it, each gated by the same `bookings.manage`\nability. Turn that off with `admin.auto_register_cms_nav` when you would rather\nplace the screens in the shell's menu yourself.\n\nA host that brings its own admin — an Inertia or React back office that may not\neven install Livewire — turns the package's screens off wholesale with\n`admin.routesEnabled` (default `true`). Off, `routes/admin.php` is not mounted at\nall and the cms-framework nav entries go with it, so the host is not handed links\ninto screens that no longer exist. The services, models, events, public API, and\niCal feeds are untouched; this decides only whether the package's own screens\nexist, not who may reach them.\n\n## Extending\n\n### Contracts\n\nSix seams are interfaces under `ArtisanPackUI\\Bookings\\Contracts`. Bind your own\nimplementation and the package uses it instead of the default:\n\n| Contract | Replaces |\n| --- | --- |\n| `SlotResolver` | how availability rules become bookable slots |\n| `RoundRobinStrategy` | which provider is assigned to a slot |\n| `CalendarSyncDriver` | how one external calendar system is talked to |\n| `NotificationChannel` | how one lifecycle message is delivered |\n| `SmsDriver` | which gateway a text message is handed to |\n| `MeetingTypeRegistry` | which shapes a service can be booked in |\n\n`MeetingTypeRegistry` is the odd one out: bind it only to replace the registry\nitself. To add a meeting type — the common case — implement\n`ArtisanPackUI\\Bookings\\Contracts\\MeetingType` (or construct a\n`RegisteredMeetingType`) and contribute it through the filter described under\n[Hooks](#hooks). No binding required.\n\nSite resolution is deliberately not on this list. It is\n`ArtisanPackUI\\Core\\Contracts\\SiteResolver`, bound once for the whole ecosystem —\nsee [Multi-site](#multi-site).\n\n### Events\n\nEvery lifecycle change dispatches a typed event under\n`ArtisanPackUI\\Bookings\\Events`. Payloads are serializable, so a listener may be\nqueued:\n\n`BookingRequested`, `BookingConfirmed`, `BookingRescheduled`, `BookingReassigned`,\n`BookingCancelled`, `BookingCompleted`, `BookingNoShow`, `SeriesCreated`,\n`SeriesCancelled`, `SeriesEdited`, `CalendarSynced`, `CalendarSyncFailed`,\n`CalendarConnectionDisabled`, and `WebhookDisabled`.\n\nEvery one implements `ShouldDispatchAfterCommit`, so a listener never runs before\nthe row it describes is committed. `BookingReassigned` carries the\n`previousProviderId` (null when the booking was previously unassigned).\n\n`BookingCancelled` carries a `BookingActor` because \"the customer cancelled\" and\n\"we cancelled on the customer\" are the same status change and completely\ndifferent events downstream. `SeriesEdited` carries a `SeriesEditScope` for the\nsame reason.\n\nThese names are public API. They are what `artisanpack-ui/crm` will subscribe to,\nand they will not be renamed without a deprecation cycle.\n\n### Hooks\n\nActions and filters are registered through `artisanpack-ui/hooks`. Names take an\n`ap.` prefix, `.`-separated segments, and camelCase within each segment — so both\n`ap.bookings.registeredMeetingTypes` and grouped names like\n`ap.bookings.calendarSync.providers` are well formed. Never snake_case.\n\nActions fire; filters transform a value and must return one.\n\n| Hook | Type | Payload |\n| --- | --- | --- |\n| `ap.bookings.creating` | action | `(array $attributes, ?Authenticatable $customer)` |\n| `ap.bookings.created` | action | `(Booking $booking)` |\n| `ap.bookings.confirmed` | action | `(Booking $booking)` |\n| `ap.bookings.rescheduling` | action | `(Booking $booking, CarbonImmutable $newStart)` |\n| `ap.bookings.rescheduled` | action | `(Booking $booking, CarbonImmutable $oldStart)` |\n| `ap.bookings.cancelling` | action | `(Booking $booking, string $reason)` |\n| `ap.bookings.cancelled` | action | `(Booking $booking)` |\n| `ap.bookings.completed` | action | `(Booking $booking)` |\n| `ap.bookings.noShow` | action | `(Booking $booking)` |\n| `ap.bookings.reassigned` | action | `(Booking $booking, ?int $previousProviderId)` |\n| `ap.bookings.series.editApplying` | action | `(BookingSeries $series, string $scope, array $changes)` |\n| `ap.bookings.manageTokenReissued` | action | `(Booking $booking, string $plainToken)` |\n| `ap.bookings.manageTokensReissued` | action | `(int $count)` |\n| `ap.bookings.icalTokenIssued` | action | `(ServiceProvider $provider, string $plainToken)` |\n| `ap.bookings.icalTokenRevoked` | action | `(ServiceProvider $provider)` |\n| `ap.bookings.calendarSync.providers` | filter | `(array $drivers)` |\n| `ap.bookings.calendarSync.pushing` | action | `(Booking $booking, string $providerSlug)` |\n| `ap.bookings.calendarSync.pushed` | action | `(Booking $booking, string $providerSlug, string $externalEventId)` |\n| `ap.bookings.calendarSync.pullReceived` | action | `(array $payload, string $providerSlug)` |\n| `ap.bookings.calendarSync.eventPayload` | filter | `(array $payload, Booking $booking, string $providerSlug)` |\n| `ap.bookings.calendarSync.connectionDisabled` | action | `(CalendarConnection $connection, string $reason)` |\n| `ap.bookings.calendarSync.renewChannels` | filter | `(int $renewed, Collection $due)` |\n| `ap.bookings.availableProviders` | filter | `(array $providers, Service $service, CarbonImmutable $start)` |\n| `ap.bookings.roundRobin.selectProvider` | filter | `(?ServiceProvider $selected, array $candidates, Booking $draft)` |\n| `ap.bookings.intakeSchema` | filter | `(array $schema, Service $service, int $version)` |\n| `ap.bookings.availabilityQuery` | filter | `(Builder $query, array $criteria)` |\n| `ap.bookings.availableSlots` | filter | `(array $slots, ServiceProvider $provider, CarbonPeriod $window)` |\n| `ap.bookings.slotBookable` | filter | `(bool $bookable, Slot $slot, ?Authenticatable $customer)` |\n| `ap.bookings.slotDuration` | filter | `(int $minutes, Service $service, ServiceProvider $provider)` |\n| `ap.bookings.registeredMeetingTypes` | filter | `(array $types)` |\n| `ap.bookings.notification.sending` | filter | `(BookingNotification $notification, Booking $booking)` |\n| `ap.bookings.notification.channels` | filter | `(array $channels, string $event, Booking $booking)` |\n| `ap.bookings.notification.subject` | filter | `(string $subject, BookingNotification $notification, Booking $booking)` |\n| `ap.bookings.reminderScheduling` | filter | `(array $hoursBefore, Booking $booking)` |\n\nFour of these have rules worth stating outright.\n\n`ap.bookings.creating` fires inside the slot lock, and can fire more than once\nfor a single `create()` call — once per provider tried, when a lost race falls\nthrough to the next candidate. `ap.bookings.created` fires exactly once. Neither\ncan cancel a booking: subscribe to the `BookingRequested` event and cancel it\nthere, which is a real cancellation with a freed slot rather than an abort inside\na held lock.\n\n`ap.bookings.roundRobin.selectProvider` returning `null` means \"no opinion\" and\nleaves the default rota's answer standing. Returning somebody who is not in\n`$candidates` throws — they were not free for the slot. It does not fire at all\nwhen the customer named their provider by hand.\n\n`ap.bookings.series.editApplying` fires once per scoped series edit, before any\nof it lands, so a subscriber reading the series back still sees the rule it is\nabout to replace. `$scope` is the string `this`, `this_and_following`, or `all` —\nthe same value the `SeriesEdited` event carries. The occurrences a series edit\nwrites and discards go through `BookingService` like any other booking, so they\nfire `ap.bookings.created` and `ap.bookings.cancelled` once each, per occurrence.\n\n`ap.bookings.manageTokenReissued` carries a live secret — the plain manage token,\nat the only moment it is readable. It is there so an emergency rotation can be\nfollowed by new links reaching customers; do not log it, and do not put it\nanywhere the hash was kept out of.\n\n`ap.bookings.icalTokenIssued` carries a live secret for the same reason: the plain\ncalendar feed token, at the only moment it is readable, which is the whole\ncredential behind a provider's diary. It fires on a rotation as well as on a first\nissue, and by the time it does the provider's previous token is already dead — so\na subscriber that delivers the new subscription URL is not being helpful, it is\nthe only thing standing between the provider and a feed that has silently stopped\nupdating.\n\n`ap.bookings.calendarSync.pullReceived` carries the external calendar's raw change\nfeed — personal data that is not this package's. The payload holds event titles\nand descriptions, organiser and attendee email addresses, and the shape of the\nprovider's private diary, handed over before normalisation so a subscriber can see\nwhat the calendar actually sent. Do not log it, and treat anything drawn from it as\nthird-party data subject to the same handling as any other personal information.\n\n`ap.bookings.intakeSchema` runs against the version a booking was captured with\nrather than the service's current form, and its output is never written back. A\nsubscriber is describing how a form should be read, not editing the record of\nwhat was asked.\n\nThe four notification filters all run *before* the log row is claimed, which is\nwhat keeps them inside the idempotency guarantee rather than outside it.\n`ap.bookings.notification.sending` returns the notification to send, a\nreplacement for it, or `null` to suppress the send entirely; returning anything\nelse throws, because a subscriber meaning to veto says so with `null` and\nanything else is a mistake worth surfacing. It runs once per channel, so\nsuppressing the customer's email still leaves the admin's database copy.\n`ap.bookings.reminderScheduling` filters the cadence in whole hours before the\nstart, and duplicate windows are collapsed — a subscriber appending `24` to a\nconfig that already has it changes nothing rather than fighting the unique index\non every cron run. A window *longer* than anything in config also needs\n`notifications.reminder.max_lookahead_hours` raised to match, since the sweep has\nto decide how far ahead to look before it has a booking to hand the filter.\n\nMeeting types are contributed through a filter rather than being hard-coded:\n\n```php\nuse ArtisanPackUI\\Bookings\\MeetingTypes\\RegisteredMeetingType;\n\naddFilter( 'ap.bookings.registeredMeetingTypes', function ( array $types ): array {\n    $types[] = new RegisteredMeetingType(\n        'webinar',\n        'Webinar',\n        'Broadcast to many attendees at once.',\n        allowsMultipleAttendees: true,\n    );\n\n    return $types;\n} );\n```\n\nPass the label and description untranslated — they are used as translation keys\nand run through `__()` when read, so they follow the current locale rather than\nfreezing at whichever one was active when the registry was first resolved.\n\nThe filter runs on every read, so registering from a service provider that boots\nafter this one still works. Entries are keyed by the type's own `key()`, so\nappending and assigning behave identically. The four built-ins — `one_to_one`,\n`group`, `recurring`, and `round_robin` — are ordinary entries: register a type\nunder an existing key to replace it.\n\n#### The registry\n\n`Support\\HookRegistry` is the machine-readable version of the table above, and\nthe whole list: it also names the hooks whose surfaces — calendar sync,\nnotifications — are not built yet, so a subscriber can be written against a name\nbefore the code that fires it exists.\n\n```php\nuse ArtisanPackUI\\Bookings\\Support\\HookRegistry;\n\nHookRegistry::all();      // every hook, with its type and the issue that fires it\nHookRegistry::shipped();  // the ones firing today — the table above\nHookRegistry::pending();  // declared, not yet fired\n```\n\nNothing inside the package reads it; hook names stay as literals at their call\nsites. What it is for is the test that holds the two lists together — every\nshipped name is fired somewhere in `src/`, and every `ap.bookings.*` literal in\n`src/` is declared here — so a surface cannot ship without its hook, and a hook\ncannot ship undocumented.\n\n## Optional integrations\n\nThe package runs standalone in any Laravel application. When these are installed\nit uses them, and degrades cleanly when they are not:\n\n- `livewire/livewire` — the public booking widget and the admin screens\n- `artisanpack-ui/cms-framework` — admin navigation, permissions, settings\n- `artisanpack-ui/livewire-ui-components` — admin screen rendering (the public widget is plain HTML and does not use it)\n- `artisanpack-ui/forms` — `booking_slot` field type, booking-from-submission\n- `artisanpack-ui/","readmeFilename":"README.md"}