Skip to content

Notification System

Volāre uses Laravel’s notification system to send alerts via email and database channels.

  • In-app notifications when offers are created (database channel)
  • Booking, payment, and lead alerts to Volāre staff and customers
  • System alerts requiring in-app visibility

Required env vars:

  • MAIL_MAILER=smtp
  • MAIL_HOST=mailpit (local) or SMTP server
  • MAIL_PORT=1025 (local) or SMTP port
  • MAIL_FROM_ADDRESS="noreply@volare.test"
  • MAIL_FROM_NAME="Volare"

Queue worker: Must be running for OfferCreatedNotification (queued)

Terminal window
./vendor/bin/sail artisan queue:work

Database: Notifications stored in notifications table

Notifications use different channels depending on their purpose:

Notification Channel Delivery
OfferCreatedNotification database Queued (async)
BookingSubmittedToVolareNotification mail + database Queued (async)
BookingSubmittedToSupplierNotification mail + database Queued (async)
StopSaleCreatedToVolareNotification mail + database Queued (async)
QuotationRequestedNotification mail (AnonymousNotifiable) / database (User) Queued (async)
CheckoutContinuationNotification mail Queued (async)
LeadingPriceAlertNotification mail (Volāre inbox + configured copies) Queued (async, via scheduled command)
ProductActivatedNotification mail (configured recipients) Queued (async, via model observer)
BookingMainContactCapturedNotification mail Queued (async)
BookingPaymentConfirmedNotification mail Queued (async)
BalancePaymentRequestNotification mail Queued (async)
AllotmentExhaustedNotification mail Queued (async)
  • Admin users — Via Role::Admin enum (Spatie permissions)
  • Supplier users — Via User.supplier_id linking users to a supplier

Configurable Email Copies (System → Emails)

Section titled “Configurable Email Copies (System → Emails)”

Operational emails registered in the TransactionalEmail enum can have extra copy recipients (“watchers”) managed in the System → Emails admin (EmailConfiguration + EmailCopyRecipient rows). At each dispatch site, TransactionalEmailCopier::fanOut() sends every active recipient their own copy through the standard notification mail path. The list page keeps rows in sync with the registry (creates missing keys, prunes stale ones), and the edit page’s “Send preview” renders the email from a recent representative record via TransactionalEmailSampleFactory: booking-submitted emails sample the latest booking with passengers (so previews never show an empty passenger list), checkout continuation samples one with a resumption token, and quotation emails one in a quotation status.

The per-passenger trip access email (trip_access_shared) is the one registered email whose copies are sanitized: watchers receive a single copy per booking without the magic-link CTA, and its preview is sanitized the same way — see Trip Access — Email Template.

Source: backend/app/Enums/TransactionalEmail.php, backend/app/Services/Email/ (TransactionalEmailCopier, EmailCopyService, TransactionalEmailSampleFactory)

In-app notification sent when new offers are created. Appears in the Filament admin notification bell.

OfferObserver::created() — After SKU generation completes

Source: backend/app/Observers/OfferObserver.php

  • All users with Role::Admin
  • Users linked to offer’s supplier via: Offer → SupplierTourRate → SupplierTour → Supplier → Users
  • Merged and deduplicated before sending
[
'title' => 'New Offer Created',
'message' => 'Offer {SKU} has been created.',
'offer_id' => $offer->id,
'sku' => $offer->sku,
'final_price' => $offer->final_price,
]

Source: backend/app/Notifications/OfferCreatedNotification.php

Two queued notifications fire once payment has been confirmed and the booking has been finalized (passengers attached, upsells persisted). Both are dispatched from BookingStatusService::notifyBookingSubmitted() inside the routing transition’s transaction. The Volāre and supplier legs run in separate try/catch blocks so one failing leg cannot silence the other; failures are reported (Sentry/Nightwatch) and logged, but never break the status transition.

Trigger: BookingStatusService::transition() detects from_status === BookingStatus::PaymentProcessing AND to_status ∈ {PendingFlightBooking, PendingLandConfirmation} and calls notifyBookingSubmitted() after the booking row is updated and the transition row is recorded. By this point BookingFinalizationService::finalizeFromCheckoutSession has committed the passenger pivot, so the email renders the real pax count and names. Firing earlier on Checkout → PendingPayment produced Pasajeros: 0 because passengers had not been created yet.

Source: backend/app/Services/Booking/BookingStatusService.php

Routed to the email of the active VolareEntity (VolareEntity::current()->email). Email lists booking reference, status, client, passenger count, departure/return dates, tour name, and the suppliers attached to the tour. The Filament bell entry links to the full BookingResource.

Source: backend/app/Notifications/BookingSubmittedToVolareNotification.php

Sent to all users of every supplier attached to the booked tour (offer.supplierTourRate.tour.suppliers.users). A supplier with no panel users is not skipped: the email falls back to the supplier’s operational address (suppliers.email), passing the supplier to the notification so the greeting and locale still match the partner. A supplier with neither users nor an email is reported as a RuntimeException so the delivery gap is visible instead of silent; a tour with no linked supplier at all logs a warning. Pricing is intentionally omitted — the email contains the tour name, the outbound departure datetime from origin, the outbound arrival datetime at the destination (prefixed with the destination IATA code, with destino/destination as a locale-aware fallback when the code is missing), and passenger count + names as a bullet list. After the operational block, the email closes with an “Urgente” call-to-action prompting the supplier to review hotels, activities, and special requirements; below the CTA a sign-off and a support contact line (bookings@byvolare.com) are rendered. The CTA links to the supplier-facing Trips to Deliver page, not the full booking. The default mail salutation is suppressed.

Flight datetimes are read from the offer’s dynamic flight cache (offer.offerFlights.flightCache.segments filtered to leg_sequence = 1), not from FlightBooking — by the time the email fires the booking is in PendingFlightBooking / PendingLandConfirmation, and the airline-booking call (FlightBooking::create) hasn’t run yet. When no outbound leg is bound, both datetime values render as em-dashes.

Localization: Email body is translated via lang/{en,es,ca,de}/notifications.php under the booking_submitted_supplier.* key. Locale is resolved from the supplier’s source_locale (via the recipient user’s supplier, or the supplier passed to the notification on operational-email fallback sends): the primary segment is taken (e.g. es_CRes) and only en/es/ca/de are accepted; anything else falls back to es.

The Filament bell entry uses Filament\Notifications\Notification::make()->getDatabaseMessage() and links to TripToDeliverResource::view.

Source: backend/app/Notifications/BookingSubmittedToSupplierNotification.php, backend/lang/{en,es,ca,de}/notifications.php

Sent when a StopSale row is created. Dispatched from StopSale::booted() (created hook) to VolareEntity::current()->email so Volāre staff are aware of every newly opened stop-sale window. Failures are logged and never bubble up out of the model event.

Source: backend/app/Notifications/StopSaleCreatedToVolareNotification.php, backend/app/Models/StopSale.php

See Stop Sales for the feature.

Two notifications support the non-standard-pax quotation flow.

Fired from BookingFunnelService::requestQuotation() when a non-standard-pax booking stops at Main Contact. The channel is chosen by recipient: an AnonymousNotifiable (the reservations inbox mail route) receives mail, while panel User recipients (sales agents + admins, via Role::SalesAgent / Role::Admin) receive a database (Arkana bell) entry. Failures are logged and swallowed so they never break the checkout response.

The reservations inbox address comes from VolareEntity::current()->reservationsEmail(), which falls back to reservas@byvolare.com when the admin-editable reservations_email column is empty.

Source: backend/app/Notifications/QuotationRequestedNotification.php

Mail to the customer, sent by an agent action once DMC availability is confirmed (booking in QuotationConfirmed). The CTA links to the Travelers step via Booking::getCheckoutContinuationUrl(), with a unique per-send sig signature appended so opens of this email’s link can be attributed in the booking’s Preview Views section (opens of a bare ?resume= URL are labelled as copied/shared instead).

Source: backend/app/Notifications/CheckoutContinuationNotification.php

Four queued mail-channel notifications cover the booking lifecycle from lead capture through payment. All are ConfigurableCopyEmail (registered in TransactionalEmail, so extra copy recipients apply — see below) and pin ->locale('es') on the instance so the language survives queueing.

Notification Recipient Fires when
BookingMainContactCapturedNotification Reservations inbox + configured copies (internal only) A lead submits contact details on the checkout Main Contact step. Dispatched once per booking, guarded by bookings.main_contact_notified_at so a contact edit + resubmit does not re-send.
BookingPaymentConfirmedNotification Booking payer (contact_email, client.email fallback) A successful payment lands. Renders the designed emails.payment-confirmed template.
BalancePaymentRequestNotification Customer A Volāre agent generates a balance payment link from the booking admin page; carries the amount, due date, and payment link.
AllotmentExhaustedNotification Volāre entity email + configured copies (internal only) A booking exhausts allotment for one or more (rate, service date) tuples. One aggregated mail per booking, dispatched from AllotmentService::consumeAllotment via DB::afterCommit.

Payment-flow details are covered in depth in Payment.

Source: backend/app/Notifications/{BookingMainContactCaptured,BookingPaymentConfirmed,BalancePaymentRequest,AllotmentExhausted}Notification.php

Email sent daily at 07:00 Europe/Madrid to the Volare entity email when a product’s leading (“desde”) price rises above a threshold versus the previous day, or its bookable anchor disappears — so the “from” price advertised in paid ads can be re-synced with the product page.

Terminal window
# Normal execution (run by scheduler)
./vendor/bin/sail artisan products:check-leading-price
# Preview without persisting snapshots or sending the alert
./vendor/bin/sail artisan products:check-leading-price --dry-run
# Custom drift threshold (percent) and/or a single product
./vendor/bin/sail artisan products:check-leading-price --threshold=5 --product=44
  • The leading price is read via ProductByMarket::getLeadingPrice() — the same value the product page renders — and stored as one product_leading_price_snapshots row per product per day.
  • Each run compares today’s price against the most recent snapshot from a prior day. The first run for a product establishes a baseline and never alerts.
  • Only increases above the threshold or a disappeared anchor (nothing bookable today) are flagged; price drops are benign.

Configuring the Threshold (System → Emails)

Section titled “Configuring the Threshold (System → Emails)”

The percent threshold is edited from the System → Emails admin, on the “Leading-price drift alert” row (alert_threshold_percent). When left empty the command falls back to 2%. The row’s Active toggle switches the alert off without a deploy — snapshots keep being recorded (so the baseline stays current), only the email is suppressed. An explicit --threshold= flag on the command overrides the configured value for ad-hoc runs.

The primary recipient is the Volāre entity email (VolareEntity::current()->email). Like the other transactional emails, extra recipients can be added under Manage recipients in System → Emails — each configured recipient receives their own copy, fanned out via TransactionalEmailCopier. The Send preview action renders a representative sample of the digest (one price-rise row + one anchor-gone row). The alert is a single aggregated digest listing every flagged product with its old → new price and a link to the product edit page in the admin.

Schedule::command('products:check-leading-price')
->dailyAt('07:00')
->timezone('Europe/Madrid')
->withoutOverlapping()
->onOneServer();

Source: backend/app/Console/Commands/CheckLeadingPriceCommand.php, backend/app/Notifications/LeadingPriceAlertNotification.php, backend/app/Models/ProductLeadingPriceSnapshot.php

Email sent to the team when a product-by-market transitions to active (becomes visible on the web), so a new itinerary going live is announced automatically instead of by a manual email.

ProductByMarketObserver::updated() fires the notification when a product’s status changes to active (draft/inactive → active). Creating a product already active — imports, duplication, factories — does not announce; only the transition does, and only once per activation.

Configured in System → Emails (“Product activated — to team”), seeded with team@byvolare.com by default. Each configured recipient receives their own copy via TransactionalEmailCopier. The Active toggle switches the announcement off; with no recipients (or the config off) nothing is sent.

Spanish email with the product name, market, a link to its public page (ProductByMarket::getPublicUrl()) and an Arkana edit link. Send preview renders a sample from the most recent product.

Source: backend/app/Observers/ProductByMarketObserver.php, backend/app/Notifications/ProductActivatedNotification.php

All emails use published vendor mail views with custom Volāre branding:

  • Header: Volāre SVG logo (replaces default Laravel logo)
  • Footer: “VOLĀRE” branding

Source: backend/resources/views/vendor/mail/html/header.blade.php, message.blade.php

Terminal window
# Send test notification to first user
./vendor/bin/sail artisan notification:test
# Send to specific email
./vendor/bin/sail artisan notification:test user@example.com

Source: backend/app/Console/Commands/SendTestNotification.php

Terminal window
# Offer created notification tests
./vendor/bin/sail artisan test --filter=OfferCreatedNotification

Test suites:

  • backend/tests/Feature/Notifications/OfferCreatedNotificationTest.php
  • backend/tests/Feature/Console/CheckLeadingPriceCommandTest.php
  • OfferCreatedNotification is queued — queue worker must be running

  • LeadingPriceAlertNotification is queued even though it fires from a scheduled command — the queue worker must be running for the alert to send

  • Admin users receive in-app notifications for every offer

  • SKU must be generated before notification (handled by observer)

Database notifications appear in Filament admin panel:

  • Notification bell icon in header
  • Unread count badge
  • Click to view details
  • Mark as read functionality

Use Mailpit for email testing:

  • Web UI: http://localhost:8025
  • SMTP: localhost:1025
  • View all sent emails without actual delivery
  • Test email templates and content