justinholtweb / craft-lyfe
Lifecycle marketing automation for Craft Commerce — a self-hosted trigger, condition and action engine for post-purchase follow-ups, review requests, replenishment reminders, win-backs, abandoned carts, segment tagging and SMS.
Package info
github.com/justinholtweb/craft-lyfe
Type:craft-plugin
pkg:composer/justinholtweb/craft-lyfe
Requires
- php: ^8.2
- ext-json: *
- craftcms/cms: ^5.3.0
- craftcms/commerce: ^5.0.0
Requires (Dev)
None
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-08-29 13:36:58 UTC
README
Lifecycle marketing automation for Craft Commerce 5 — a trigger → condition → action engine that runs on your own server.
Post-purchase follow-ups. Review requests. Replenishment reminders. Win-backs. Abandoned carts. VIP tagging. First-order-versus-repeat copy. SMS. All of it driven by rules you write in the control panel, against data that never leaves your database.
Why this exists
Craft Commerce has a real gap here.
| What's out there | What it does | What it doesn't |
|---|---|---|
verbb/abandoned-cart |
One job, done well: email people who left a cart | No other triggers, no conditions, no multi-step sequences |
| Klaviyo Connect, Omnisend and friends | Ship your customer data to a SaaS | Your data leaves your stack, and you pay per contact forever |
| Commerce's status emails | One email per status change | No delays, no conditions, no segments |
Nobody ships a self-hosted rules engine over Commerce events. AutomateWoo ($99/yr) is the reference implementation on the WooCommerce side; Lyfe is the Craft answer, and it fits the "own your stack" position that made you choose Craft in the first place.
Requirements
- Craft CMS 5.3+
- Craft Commerce 5.0+
- PHP 8.2+
Installation
composer require justinholtweb/craft-lyfe php craft plugin/install lyfe
Then give it something to run on a schedule:
* * * * * /path/to/project/craft lyfe/run/due
That single command advances due steps, sweeps abandoned carts and runs scheduled contact scans. If you have no cron, turn on Also process during web requests in the settings — it pushes a throttled queue job instead. It is never inline: an automation must not be able to slow down a customer's checkout.
How a workflow works
Three parts, in the order the editor asks for them.
1. When — a trigger. An order was placed. An order moved into "shipped". Somebody registered. A cart went quiet. A contact got tagged. Or nothing at all: a scheduled scan that walks your contacts on a timer, which is the only way to catch a customer who has simply stopped buying.
2. Only if — conditions. Order total, items, products, SKUs, coupon, gateway, shipping country, status. Lifetime spend, order count, days since last order, tags, user groups, consent. Day of week, hour of day. Rules in a group must all be true; any one group being true is enough.
3. Then — an ordered list of actions, each with its own delay before it happens. Send an email. Send an SMS. Tag the contact. Note it on the order. Change the order status. Generate a one-off discount code. Call a webhook. Stop the run if they have already bought.
Which reads, top to bottom, as a timeline:
When an order is placed
Only if it is the customer's first order
Then immediately tag them "first-time-buyer"
after 2 days email "Welcome — a few things worth knowing"
after 12 days email "How did we do?"
What it will not do to you
These are design decisions, not omissions.
- Nothing fires twice. Every run carries a deduplication key, enforced by a unique database index. A retried webhook, a Commerce event that fires twice, an overlapping sweep — all collide there and are dropped.
- A queue outage does not deliver nine days of mail at once. A step more than
staleAfterHoursoverdue is abandoned and recorded, not sent late. - Conditions are re-checked before every step. In the nine days between "order placed" and "how did we do", the customer may have unsubscribed, refunded, or bought again. Lyfe looks again before it sends.
- Unsubscribed contacts are never marketed to. The refusal is recorded rather than silent — "why did this customer not get the email" is the question the message log exists to answer.
- An automation cannot break checkout. Every listener is wrapped; a failure is logged, not raised.
- Dry run means dry run. Per workflow or globally. Everything runs and is recorded; nothing is sent.
The simulator
Pick a real order or contact and Lyfe tells you, rule by rule, which workflows would run and why the rest would not:
✗ Win back a lapsed customer
· The conditions do not match.
✓ Subscribed to marketing email (yes) is yes
✗ Days since last order (12) is at least 120
It shares its code path with the engine — Conditions::explain() and Runs::executeStep() are
the same methods a live run uses, with a simulate flag — so what it shows you is what actually
happens. There is a console version too: php craft lyfe/workflows/simulate <orderId>.
The library
Fourteen presets, each installing disabled with placeholder copy plainly marked EDIT ME:
| Preset | |
|---|---|
| Post-purchase thank you | An hour after checkout |
| Review request | Two weeks after completion |
| Replenishment reminder | 45 days after a chosen product |
| New account welcome | Plus a nudge if they haven't ordered |
| First-time buyer follow-up | Only fires on order one |
| Repeat customer follow-up | The other half of the split |
| Abandoned cart recovery | Two emails, each stopping once they buy |
| Stop chasing recovered carts | Cancels the chase when the order lands |
| Win back a lapsed customer | Daily scan, 120 days quiet |
| Clear the lapsed tag | When they come back |
| Tag VIP customers | Weekly scan on lifetime spend |
| Text the customer when an order ships | SMS on status change |
| Alert the team to a large order | Internal, transactional |
| Follow up on a refund | The cheapest customer research there is |
Editions
Lite — $99. The whole engine: every order and account trigger, every condition, multi-step workflows with delays, email, tagging, order notes, webhooks, the simulator, run and message logs, the console commands, and every preset it can run.
Pro — $179. Everything that needs standing infrastructure:
- abandoned cart tracking, the
cart.abandonedtrigger and recovery links - scheduled contact scans — win-backs and VIP tagging on a timer
- SMS, through Twilio or your own HTTP gateway
- unique, expiring discount code generation
- order status and user group actions
- send windows, per-contact run limits and cooldowns
- the
contact.taggedchaining trigger, so one workflow can hand off to the next - cancelling another workflow's pending runs
A licence downgrade is handled honestly: a Pro workflow refuses to start rather than running with its Pro steps quietly skipped, and the reason is written to the log.
Contacts
Lyfe keeps its own contact record, because most Commerce customers never register an account and consent has to live somewhere. Contacts are created automatically from orders and registrations, identified by lowercased email, and carry tags, a phone number, and per-channel subscription flags.
To build them from your existing orders:
php craft lyfe/contacts/backfill
Email templates
Subjects and bodies are Twig, rendered against the payload:
Hi {{ contact.firstName ?? 'there' }},
Your order {{ order.reference }} for {{ order.totalPrice|commerceCurrency(order.currency) }}
is on its way.
{% if stats.orderCount > 1 %}Thanks for coming back.{% endif %}
<a href="{{ unsubscribeUrl }}">Unsubscribe</a>
Available: contact, order, user, workflow, stats, data, unsubscribeUrl. Set an
email layout template in the settings to wrap every body in your own HTML.
For abandoned carts, {{ craft.lyfe.cartRestoreUrl(order) }} produces a link that puts the cart
back in the customer's session.
SMS
Two transports ship with Lyfe:
- Twilio — account SID, auth token and from-number, called directly over HTTP. No SDK: it is one authenticated form POST, and a plugin that adds a large dependency tree to every install it touches is a bad neighbour.
- Generic HTTP — post to any URL.
{{to}}and{{body}}are substituted into it, and a POST also sends{"to": …, "body": …}as JSON. Vonage, MessageBird, Sinch and a self-hosted gateway all work without Lyfe pretending to know their APIs.
Add your own with Sms::EVENT_REGISTER_TRANSPORTS.
Console commands
php craft lyfe/run/due # the cron entry point php craft lyfe/run/status # what's waiting php craft lyfe/run/cancel <runId> php craft lyfe/run/prune [days] # trim run, message and log history php craft lyfe/workflows # list, with run counts php craft lyfe/workflows/library # the presets php craft lyfe/workflows/install <preset> php craft lyfe/workflows/simulate <orderId> php craft lyfe/workflows/run-for-tag <workflow> <tag> php craft lyfe/contacts/backfill php craft lyfe/contacts/sync-stats php craft lyfe/contacts/unsubscribe <email>
Extending it
Triggers, conditions, actions and SMS transports are all registries keyed by a stable handle, so your own classes are first-class:
use justinholtweb\lyfe\services\Types; use justinholtweb\lyfe\events\RegisterTypesEvent; Event::on(Types::class, Types::EVENT_REGISTER_ACTIONS, function(RegisterTypesEvent $event) { $event->types[] = MyAction::class; });
Extend BaseAction, implement handle(), displayName(), settingsFields() and run().
Handles are what get stored, so your class can move without a migration.