docs/developer/core-concepts/events.mdx
import { Since } from '/snippets/since.mdx';
<Since version="5.3" />Spree includes a powerful event system that allows you to react to various actions happening in your store. When something happens (an order is completed, a product is created, etc.), Spree publishes an event that your code can subscribe to and handle.
This pattern enables loose coupling between components and makes it easy to:
Spree's event system provides a clean API through:
Spree::Events - The main module for publishing and subscribing to eventsSpree::Subscriber - Base class for creating event subscribersSpree::Publishable - Concern that enables models to publish eventsWhen an event is published, all matching subscribers are notified. By default, subscribers run asynchronously via background jobs to avoid blocking the main request.
flowchart TB
subgraph Spree Application
A[Model Action] --> B[publish_event]
B --> C[Event Serializer]
C --> D[Spree::Events]
end
subgraph Event Adapter
D --> E[Find Matching Subscribers]
E --> F{Async?}
F -->|Yes| G[Queue Background Job]
F -->|No| H[Execute Immediately]
end
subgraph Subscribers
G --> I[SubscriberJob]
I --> J[Your Subscriber]
H --> J
J --> K[Send Email]
J --> L[Sync External Service]
J --> M[Update Cache]
J --> N[Trigger Webhook]
end
The fastest path is the generator — it creates the class, a spec stub, and registers the subscriber in config/initializers/spree.rb (the step that's easy to forget):
spree generate subscriber OrderCompleted order.completed
bin/rails g spree:subscriber OrderCompleted order.completed
Or create the class by hand in app/subscribers/, inheriting from Spree::Subscriber:
class OrderCompletedSubscriber < Spree::Subscriber
subscribes_to 'order.completed'
def handle(event)
order_id = event.payload['id']
order = Spree::Order.find_by_prefix_id(order_id)
return unless order
# Your custom logic here
ExternalService.notify_order_placed(order)
end
end
Then register it in an initializer — subscribers are not auto-discovered (see Registering Subscribers):
Rails.application.config.after_initialize do
Spree.subscribers << OrderCompletedSubscriber
end
The Spree::Subscriber class provides a clean DSL for declaring subscriptions:
class MySubscriber < Spree::Subscriber
# Subscribe to a single event
subscribes_to 'order.completed'
# Subscribe to multiple events
subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
# Subscribe to all events matching a pattern
subscribes_to 'order.*' # All order events
subscribes_to '*.*' # All events (use sparingly!)
# Run synchronously instead of via background job
subscribes_to 'order.completed', async: false
end
When subscribing to multiple events, use the on DSL to route events to specific methods:
class OrderAuditSubscriber < Spree::Subscriber
subscribes_to 'order.completed', 'order.canceled', 'order.resumed'
on 'order.completed', :log_order_completed
on 'order.canceled', :log_order_canceled
on 'order.resumed', :log_order_resumed
private
def log_order_completed(event)
create_audit_log(event, 'completed')
end
def log_order_canceled(event)
create_audit_log(event, 'canceled')
end
def log_order_resumed(event)
create_audit_log(event, 'resumed')
end
def create_audit_log(event, action)
AuditLog.create!(
resource_type: 'Spree::Order',
resource_id: event.payload['id'],
action: action,
occurred_at: event.created_at
)
end
end
When your subscriber receives an event, you get a Spree::Event object with:
def handle(event)
event.id # => "550e8400-e29b-41d4-a716-446655440000" (UUID)
event.name # => "order.completed"
event.store_id # => 1 (ID of the store where the event originated)
event.payload # => { "id" => 1, "number" => "R123456", ... }
event.metadata # => { "spree_version" => "<spree_version>" }
event.created_at # => Time when event was published
# Helper methods
event.store # => Spree::Store instance (lazy loaded)
event.resource_type # => "order" (extracted from name)
event.action # => "completed" (extracted from name)
end
The payload contains serialized attributes, not the actual record. To get the record:
def handle(event)
record_id = event.payload['id']
record = Spree::Order.find_by_prefix_id(record_id)
return unless record
# Work with the record
end
Models that include Spree::Publishable and call publishes_lifecycle_events automatically publish:
| Event Pattern | Description |
|---|---|
{model}.created | Record was created |
{model}.updated | Record was updated |
{model}.deleted | Record was deleted |
For example, Spree::Price publishes price.created, price.updated, and price.deleted.
Models with lifecycle events enabled include: Order, Payment, Price, Shipment, Variant, LineItem, StockItem, and many others.
| Event | Description |
|---|---|
order.created | Order was created |
order.updated | Order was updated |
order.completed | Order checkout completed |
order.canceled | Order was canceled |
order.resumed | Canceled order was resumed |
order.paid | Order is fully paid |
order.shipped | All order shipments are shipped |
| Event | Description |
|---|---|
shipment.created | Shipment was created |
shipment.updated | Shipment was updated |
shipment.shipped | Shipment was shipped |
shipment.canceled | Shipment was canceled |
shipment.resumed | Shipment was resumed |
| Event | Description |
|---|---|
payment.created | Payment was created |
payment.updated | Payment was updated |
payment.paid | Payment was completed |
| Event | Description |
|---|---|
price.created | Price was created |
price.updated | Price was updated |
price.deleted | Price was deleted |
| Event | Description |
|---|---|
user.created | User was created |
user.updated | User was updated |
user.deleted | User was deleted |
When Spree.admin_user_class differs from Spree.user_class, admin users publish the equivalent admin.* events (see the Admin Events table below).
| Event | Description |
|---|---|
admin.created | Admin user was created |
admin.updated | Admin user was updated |
admin.deleted | Admin user was deleted |
| Event | Description |
|---|---|
product.activated | Product status changed to active |
product.archived | Product status changed to archived |
product.out_of_stock | Product has no stock left for any variant |
product.back_in_stock | Product was out of stock and now has stock again |
You can publish custom events from anywhere in your application:
Models including Spree::Publishable can use publish_event:
class Spree::Order < Spree.base_class
def mark_as_fraudulent!
update!(fraudulent: true)
publish_event('order.marked_fraudulent')
end
end
Use Spree::Events.publish directly:
Spree::Events.publish(
'inventory.low_stock',
{ variant_id: variant.id, quantity: variant.total_on_hand }
)
Event payloads are generated using the same Store API V3 serializers used by the REST API. This means webhook payloads and API responses share the same schema, making it easy to reuse types in your integrations.
When a model publishes an event, Spree looks for a V3 serializer class matching the model name:
Spree::Order → Spree::Api::V3::OrderSerializerSpree::Product → Spree::Api::V3::ProductSerializerSpree::Payment → Spree::Api::V3::PaymentSerializerFor STI models (e.g., Spree::Exports::Products), the serializer lookup walks up the class hierarchy until it finds a match (e.g., → Spree::Api::V3::ExportSerializer).
If no serializer is found, a minimal fallback payload is returned:
{ "id": "prod_86Rf07xd4z", "created_at": "2025-01-15T10:00:00Z", "updated_at": "2025-01-15T10:30:00Z" }
Spree includes V3 serializers for all core models in api/app/serializers/spree/api/v3/:
| Serializer | Model |
|---|---|
OrderSerializer | Orders with totals, statuses, nested line items, fulfillments, payments, addresses |
ProductSerializer | Products with pricing, stock status, availability |
PaymentSerializer | Payments with amounts, states, nested payment method and source |
FulfillmentSerializer | Fulfillments (shipments) with tracking, nested delivery method and delivery rates |
LineItemSerializer | Line items with quantity, pricing, nested option values |
VariantSerializer | Variants with SKU, pricing, nested option values |
PriceSerializer | Prices with amounts, currency, price list |
| ... | And many more |
Event serializers receive specific context parameters that control what data is included:
store — Prefers the resource's store (e.g., order.store), falls back to Spree::Current.storecurrency — Uses Spree::Current.currency (with full fallback chain)user: nil — Events never include user-specific pricingincludes: [] — Conditional associations are not included in event payloadsThis means event payloads contain the same top-level attributes and unconditional associations as API responses, but conditional associations (like product variants, media, or custom fields) are excluded.
To customize the payload for existing events, create a custom V3 serializer and configure it via dependencies:
module MyApp
class OrderSerializer < Spree::Api::V3::OrderSerializer
# Add custom attributes
attribute :loyalty_points do |order|
(order.total.to_f * 10).to_i
end
attribute :custom_field do |order|
order.custom_field
end
end
end
Spree.api.order_serializer = 'MyApp::OrderSerializer'
If you add a custom model that publishes events, create a V3 serializer:
module Spree
class Subscription < Spree.base_class
publishes_lifecycle_events
def renew!
update!(renewed_at: Time.current)
publish_event('subscription.renewed')
end
end
end
module Spree
module Api
module V3
class SubscriptionSerializer < BaseSerializer
typelize plan_name: :string, status: :string,
user_id: [:string, nullable: true],
renewed_at: [:string, nullable: true],
expires_at: [:string, nullable: true]
attributes :plan_name, :status,
renewed_at: :iso8601, expires_at: :iso8601,
created_at: :iso8601, updated_at: :iso8601
attribute :user_id do |subscription|
subscription.user&.prefixed_id
end
end
end
end
end
Models without a matching serializer will use a minimal fallback payload containing only id, created_at, and updated_at.
Subscribers are not auto-discovered — every subscriber must be registered explicitly, regardless of where the class lives. Add it to the Spree.subscribers array in config/initializers/spree.rb (or any initializer):
Rails.application.config.after_initialize do
Spree.subscribers << CustomSubscriber
end
To remove a built-in subscriber:
Rails.application.config.after_initialize do
Spree.subscribers.delete(Spree::ExportSubscriber)
end
By default, subscribers run asynchronously via a background job. This prevents slow subscriber code from blocking HTTP requests.
For critical operations that must complete before the request finishes, use synchronous mode:
class CriticalOrderHandler < Spree::Subscriber
subscribes_to 'order.completed', async: false
def handle(event)
# This runs immediately, blocking the request
end
end
You can disable event publishing temporarily:
Spree::Events.disable do
# Events published in this block won't trigger subscribers
order.complete!
end
This is useful for:
require 'spec_helper'
RSpec.describe OrderCompletedSubscriber do
let(:order) { create(:completed_order_with_totals) }
let(:event) do
Spree::Event.new(
name: 'order.completed',
payload: order.event_payload
)
end
describe '#handle' do
it 'notifies external service' do
expect(ExternalService).to receive(:notify_order_placed).with(order)
described_class.new.handle(event)
end
end
end
Stub Spree::Events.publish to assert an event is published:
it 'publishes order.completed event' do
expect(Spree::Events).to receive(:publish).with(
'order.completed',
hash_including('id' => order.id)
)
order.complete!
end
The lifecycle events shared examples in spree/core/lib/spree/testing_support/lifecycle_events.rb cover the standard created/updated/deleted lifecycle events.
Here's a complete example of a subscriber that sends alerts when inventory is low:
class InventoryAlertSubscriber < Spree::Subscriber
subscribes_to 'stock_item.updated'
LOW_STOCK_THRESHOLD = 10
def handle(event)
stock_item = find_stock_item(event)
return unless stock_item
return unless low_stock?(event)
send_low_stock_alert(stock_item)
end
private
def find_stock_item(event)
Spree::StockItem.find_by_prefix_id(event.payload['id'])
end
def low_stock?(event)
event.payload['count_on_hand'].to_i < LOW_STOCK_THRESHOLD
end
def send_low_stock_alert(stock_item)
InventoryMailer.low_stock_alert(
variant: stock_item.variant,
stock_location: stock_item.stock_location,
count_on_hand: stock_item.count_on_hand
).deliver_later
end
end
Spree's event system uses an adapter pattern, making it possible to swap the underlying event infrastructure. By default, Spree uses ActiveSupport::Notifications, but you can create custom adapters for other backends like Kafka, RabbitMQ, or Redis Pub/Sub.
Set your adapter class in an initializer:
Spree.events_adapter_class = 'MyApp::Events::KafkaAdapter'
Inherit from Spree::Events::Adapters::Base and implement the required methods:
module MyApp
module Events
class KafkaAdapter < Spree::Events::Adapters::Base
def publish(event_name, payload, metadata = {})
event = build_event(event_name, payload, metadata)
# Publish to Kafka
kafka_producer.produce(
event.to_json,
topic: "spree.#{event_name}"
)
event
end
def subscribe(pattern, subscriber, options = {})
registry.register(pattern, subscriber, options)
end
def unsubscribe(pattern, subscriber)
registry.unregister(pattern, subscriber)
end
def activate!
@kafka_producer = Kafka.new(
seed_brokers: ENV['KAFKA_BROKERS']
).producer
end
def deactivate!
@kafka_producer&.shutdown
end
private
attr_reader :kafka_producer
end
end
end
The Spree::Events::Adapters::Base class defines the required interface:
| Method | Description |
|---|---|
publish(event_name, payload, metadata) | Publish an event, return Spree::Event |
subscribe(pattern, subscriber, options) | Register a subscriber for a pattern |
unsubscribe(pattern, subscriber) | Remove a subscriber |
activate! | Called during application initialization |
deactivate! | Called during shutdown |
The base class also provides helper methods:
build_event(name, payload, metadata) - Creates a Spree::Event instanceinvoke_subscribers(event) - Finds and invokes matching subscribers (internally calling registry.subscriptions_for)registry - Access to the Spree::Events::Registry instance