A custom CRM and order management system for Ferguson Livestock, a family-run pasture-fed Murray Grey beef operation in Snake Valley, Victoria. Built specifically for managing direct-to-consumer beef box sales through processing cycles (drops), SMS-based customer communication, and Stripe payment integration.
This is a single-user admin application used by Dan and Tahlia Ferguson to manage their customer base, process orders, track inventory per processing cycle, and communicate with customers via SMS.
- Laravel (latest stable) — PHP framework
- Filament PHP 5 — admin panel framework (provides all UI, no custom frontend)
- SQLite (development) — database
- Twilio SDK — SMS sending, receiving, and delivery tracking
- Stripe PHP SDK — payment link generation, webhook handling, order sync
- Laravel Queues (database driver) — background job processing for SMS and Stripe sync
- Spatie Laravel Activity Log — audit trail on all models
No custom JavaScript or frontend framework is required. Filament handles all UI components including forms, tables, actions, widgets, and relation managers.
Ferguson Livestock does not have continuous supply. Cattle are processed in batches, roughly every 7–8 weeks. Each batch is referred to as a "drop" (e.g., February 2026 Drop, April 2026 Drop).
Each drop has:
- A name/label (e.g., "February 2026")
- A status:
planning → active → sold_out → complete - A defined inventory of products with per-cycle pricing
- Associated orders
- Associated expenses for profitability tracking
There are three products:
- 5kg Beef Box — the core unit of inventory
- 500g Beef Mince — optional extra, purchased alongside a box
- 2kg Soup & Stock Bones — optional extra, purchased alongside a box
Mince and bones are add-ons. They are never sold independently — they are only available as extras when a customer purchases a box.
Larger box options (10kg, 20kg) are purchase bundles made up of 5kg box units:
- 5kg = 1× 5kg box unit
- 10kg = 2× 5kg box units
- 20kg = 4× 5kg box units
Inventory per cycle is tracked as three simple numbers: total 5kg box units, total mince packs, total bones packs. When a 10kg order is placed and paid, 5kg box inventory decrements by 2.
Pricing varies between drops for A/B testing purposes. Each cycle defines its own prices and corresponding Stripe Price IDs:
| Product | Example (Feb 2026) | Example (Apr 2026) |
|---|---|---|
| 5kg box | $150 (price_abc) |
$170 (price_def) |
| 10kg box (2×5kg) | $260 (price_ghi) |
$270 (price_jkl) |
| 20kg box (4×5kg) | TBD | TBD |
| 500g mince | TBD | TBD |
| 2kg bones | TBD | TBD |
Each product variation within a cycle stores its own Stripe Price ID. When the CRM generates a payment link, it uses the Price ID from the active cycle.
A contact is a customer or prospective customer. Contacts can originate from:
- Website form submission (fergusonlivestock.com.au)
- Stripe purchase (synced via webhook)
- CSV import (one-time Klaviyo migration)
- Manual creation in the CRM
Contact lifecycle status: lead → customer → inactive
- Lead — has registered interest (form submission) but has never purchased
- Customer — has at least one completed (paid) order
- Inactive — has not ordered in a configurable number of cycles (used for re-engagement targeting)
Contacts can be tagged for segmentation (e.g., "Ballarat", "5kg preference", "10kg preference", "VIP", "April 2026 interested").
Contacts may arrive from multiple sources with slight variations. The CRM needs a ContactMerger service:
- Automatic matching: match on phone number first (most reliable), then email
- Manual merge UI: Filament action to merge two contacts, preserving all order history, SMS history, and activity from both records
- On inbound webhook (Stripe or form), check for existing contact before creating a new one
Orders are synced from Stripe via webhooks. An order belongs to a contact and a processing cycle.
Order statuses mirror Stripe's native payment statuses (e.g., pending, paid, expired, refunded). No custom status workflow — keep it simple.
An order contains:
- Link to contact
- Link to processing cycle
- Stripe Payment Intent ID / Checkout Session ID
- Line items (which products and quantities)
- Total amount
- Status (from Stripe)
- Timestamps
When an order is confirmed as paid via Stripe webhook, the CRM automatically decrements inventory on the associated cycle.
SMS is the primary communication channel. Powered by Twilio with a dedicated Australian mobile number.
Capabilities:
- Send individual SMS from a contact's detail page
- Send bulk SMS to filtered/tagged contact segments
- SMS templates with merge fields:
{first_name},{suburb},{cycle_name},{payment_link}etc. - Inbound reply handling via Twilio webhook (logged against the contact)
- Automatic opt-out handling: if a contact replies STOP, mark them as opted out and exclude from all future sends
- Delivery status tracking via Twilio status callbacks (queued, sent, delivered, failed)
- Chat-style SMS thread view on each contact's detail page
Twilio forwarding setup: Inbound SMS to the Twilio number should be forwarded to Dan's personal mobile number so replies can be handled conversationally outside the CRM if needed. Replies are also logged in the CRM via webhook.
Spam Act 2003 compliance:
- Store opt-in timestamp per contact (when they submitted the form or explicitly consented)
- Honour STOP/unsubscribe replies automatically
- Only send marketing SMS to contacts with valid opt-in
A2P registration: The Twilio number will need to be registered for Application-to-Person messaging with Australian carriers. This should be noted in deployment documentation.
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| first_name | string | |
| last_name | string | nullable |
| string | nullable, unique where not null | |
| phone | string | nullable, unique where not null, E.164 format |
| suburb | string | nullable |
| postcode | string | nullable |
| status | enum | lead, customer, inactive |
| source | enum | website_form, stripe, csv_import, manual |
| opted_in_at | timestamp | nullable, when they consented to SMS |
| opted_out_at | timestamp | nullable, when they sent STOP |
| stripe_customer_id | string | nullable |
| notes | text | nullable, free-form notes |
| created_at | timestamp | |
| updated_at | timestamp | |
| deleted_at | timestamp | soft deletes |
Has many: orders, sms_messages, form_submissions, tags (many-to-many)
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| name | string | unique |
| created_at | timestamp |
Many-to-many with contacts via contact_tag pivot table.
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| name | string | e.g., "February 2026" |
| status | enum | planning, active, sold_out, complete |
| notes | text | nullable |
| created_at | timestamp | |
| updated_at | timestamp |
Has many: cycle_products, orders, expenses
Defines what products are available in a cycle, their pricing, Stripe Price IDs, and inventory.
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| processing_cycle_id | foreign key | |
| product_type | enum | 5kg_box, 10kg_box, 20kg_box, 500g_mince, 2kg_bones |
| price_cents | integer | price in cents |
| stripe_price_id | string | Stripe Price ID for this product in this cycle |
| initial_stock | integer | starting inventory (for boxes, in 5kg units; for extras, in individual packs) |
| reserved_stock | integer | default 0, decremented on paid orders |
| created_at | timestamp |
available_stock is a computed attribute: initial_stock - reserved_stock
For box products, initial_stock is always in 5kg box units. A 10kg product with initial_stock of 0 and no independent stock — it draws from the 5kg box pool. The CRM should track a single box_units inventory on the cycle level and decrement appropriately:
- 5kg order → decrement 1
- 10kg order → decrement 2
- 20kg order → decrement 4
Alternatively, simplify the CycleProduct model so that only the 5kg_box, 500g_mince, and 2kg_bones entries carry initial_stock. The 10kg and 20kg entries only need price_cents and stripe_price_id since they consume 5kg box inventory. The CRM should enforce that a payment link cannot be generated if insufficient 5kg box units remain for the selected purchase option.
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| contact_id | foreign key | |
| processing_cycle_id | foreign key | |
| stripe_checkout_session_id | string | nullable |
| stripe_payment_intent_id | string | nullable |
| status | string | mirrors Stripe: pending, paid, expired, refunded |
| total_cents | integer | |
| paid_at | timestamp | nullable |
| created_at | timestamp | |
| updated_at | timestamp |
Has many: order_items
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| order_id | foreign key | |
| cycle_product_id | foreign key | |
| quantity | integer | |
| unit_price_cents | integer | price at time of purchase |
| created_at | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| processing_cycle_id | foreign key | |
| description | string | e.g., "Processing fees", "Packing materials", "Delivery fuel" |
| amount_cents | integer | |
| date | date | nullable |
| created_at | timestamp | |
| updated_at | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| contact_id | foreign key | |
| direction | enum | outbound, inbound |
| body | text | message content |
| twilio_sid | string | nullable, Twilio message SID |
| status | enum | queued, sent, delivered, failed, received |
| template_id | foreign key | nullable, if sent from a template |
| sent_at | timestamp | nullable |
| created_at | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| name | string | e.g., "Pre-sale announcement" |
| body | text | template with merge fields: {first_name}, {suburb}, {cycle_name}, {payment_link} |
| created_at | timestamp | |
| updated_at | timestamp |
| Field | Type | Notes |
|---|---|---|
| id | ulid | Primary key |
| contact_id | foreign key | nullable, linked after matching/creation |
| payload | json | raw form data as submitted |
| processed_at | timestamp | nullable, when contact was created/linked |
| created_at | timestamp |
ContactResource
- Table: searchable/filterable list with columns for name, phone, email, suburb, status, source, tags, order count, last order date
- Create/Edit form: all contact fields plus tag selector
- View page with:
- Contact details infolist
- Activity timeline (all interactions in chronological order)
- SMS thread (chat-style view using Filament infolist, with inline send form)
- Relation manager: Orders
- Relation manager: Form Submissions
- Header actions: Send SMS, Generate Payment Link, Merge with Another Contact
ProcessingCycleResource
- Table: list of all cycles with status, dates, stock remaining summary
- Create/Edit form: name, status, notes
- Relation manager: CycleProducts (define products, pricing, Stripe Price IDs, stock levels per cycle)
- Relation manager: Orders (all orders for this cycle)
- Relation manager: Expenses
- View page with:
- Inventory status panel (visual indicators for remaining stock — 5kg units, mince, bones)
- Revenue summary (auto-calculated from paid orders)
- Expenses summary
- Profit/margin calculation
- Header actions: Bulk SMS to Leads, Mark as Active, Mark as Sold Out, Mark as Complete
OrderResource
- Table: all orders with contact name, cycle, status, total, date
- Primarily read-only (synced from Stripe), but allow manual notes
- View page with order items, Stripe links, contact details
SmsTemplateResource
- CRUD for SMS templates
- Preview with sample merge field values
FormSubmissionResource
- Table: incoming form submissions, filterable by processed/unprocessed
- Action: Create Contact from Submission (pre-fills contact form with submission data)
Filament dashboard with the following widgets:
- Active Cycle Overview — current cycle name, status, inventory remaining (5kg units, mince, bones) as progress bars or stats
- Recent Orders — last 10 orders with contact name, products, total, status
- Revenue per Cycle — bar chart showing revenue across all completed and active cycles
- Cycle Profitability — table showing each cycle's revenue, expenses, profit, margin percentage
- Contacts Summary — total leads, total customers, new leads this week
- SMS Stats — messages sent this cycle, delivery rate
SendSmsAction — available on ContactResource view page. Opens a modal with a text field (or template selector + merge field preview). Dispatches SendSmsJob.
BulkSmsAction — available on ContactResource table as a bulk action. Select contacts (or use filters/tags), choose or write message, preview recipient count, dispatches BulkSmsBlastJob. Must exclude opted-out contacts. Should warn if recipient count exceeds available stock on the active cycle.
GeneratePaymentLinkAction — available on ContactResource view page. Modal to select active cycle, choose product (5kg/10kg/20kg), optionally add extras (mince, bones with quantities). Checks inventory availability. Creates a Stripe Checkout Session with the correct Price IDs from the cycle. Optionally sends the link via SMS immediately. Creates an order record with pending status.
MergeContactsAction — select a primary contact and a secondary contact. Moves all orders, SMS messages, form submissions, and activity log entries from secondary to primary. Soft-deletes secondary.
ImportCsvAction — one-time action (can be a Filament page or import action). Upload CSV exported from Klaviyo. Maps columns to contact fields. Creates contacts, skipping duplicates based on phone/email matching.
These are standard Laravel routes, not part of the Filament panel.
Handles Stripe webhook events. Must verify Stripe webhook signature.
Events to handle:
checkout.session.completed— mark order as paid, decrement cycle inventory, update contact status tocustomerif currentlylead, link to contact via Stripe customer email/metadatacheckout.session.expired— mark order as expired, release reserved inventory if applicable
Handles inbound SMS from Twilio. Must verify Twilio request signature.
Logic:
- Look up contact by phone number (sender)
- Create
SmsMessagerecord with directioninbound - If message body is STOP/UNSUBSCRIBE/CANCEL/END/QUIT, set
opted_out_aton contact - If message body is START/UNSTOP, clear
opted_out_at - Forward the message to Dan's personal number via Twilio (configurable in .env)
Handles Twilio delivery status callbacks. Updates SmsMessage status field (queued → sent → delivered or failed).
Receives form data from the Vercel API route on fergusonlivestock.com.au. Should be authenticated with a simple API key (stored in .env, passed as a bearer token or header).
Logic:
- Store raw payload as a
FormSubmission - Attempt to match to existing contact by phone number, then email
- If match found, link the submission to the contact
- If no match, create a new contact with status
leadand sourcewebsite_form, setopted_in_atto now - Add relevant tags if present in form data
All external API calls should be dispatched as queued jobs using Laravel's database queue driver.
- SendSmsJob — sends a single SMS via Twilio, creates SmsMessage record
- BulkSmsBlastJob — iterates through a collection of contacts, dispatches individual SendSmsJob per contact (allows for individual delivery tracking and failure handling)
- SyncStripeOrderJob — processes a Stripe webhook payload, creates/updates order and contact records. Must be idempotent (check for existing order by
stripe_checkout_session_idbefore creating) - ProcessFormSubmissionJob — handles contact matching/creation from a form submission
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
TWILIO_SID=
TWILIO_AUTH_TOKEN=
TWILIO_FROM_NUMBER= # The Twilio Australian mobile number
TWILIO_FORWARD_TO_NUMBER= # Dan's personal iPhone number for SMS forwarding
FORM_SUBMISSION_API_KEY= # API key for the website form endpoint
One-time import feature. Expected CSV columns (mapping should be configurable in the import UI):
- First name
- Last name
- Phone number
- Any tags/segments from Klaviyo
Import logic:
- Normalise phone numbers to E.164 format (+61...)
- Skip duplicates based on phone/email matching
- Set source as
csv_import - Set
opted_in_atbased on Klaviyo subscription date if available, otherwise import timestamp - Report: X imported, X skipped (duplicate), X failed (invalid data)
Filament has a built-in import action (ImportAction) that supports CSV uploads with column mapping. Use this rather than building a custom import page.
- Queue worker: must be running for SMS and Stripe webhook processing. Use
php artisan queue:workwith a process manager like Supervisor in production. - Scheduler: run
php artisan schedule:runvia cron for any scheduled tasks (optional, not critical for v1). - Twilio A2P registration: register the Twilio number for Application-to-Person messaging with Australian carriers before going live. Messages may be filtered/blocked without this.
- Stripe webhook endpoint: register the
/webhooks/stripeURL in the Stripe dashboard and subscribe tocheckout.session.completedandcheckout.session.expiredevents. - Twilio webhook URLs: configure the Twilio number's incoming message webhook to
/webhooks/twilio/inboundand set the status callback URL on outbound messages to/webhooks/twilio/status. - SSL: required for all webhook endpoints.
- Hosting: any standard Laravel-compatible host (Forge + DigitalOcean, Laravel Vapor, shared hosting with SSH access, etc.).
- Email sending (SMS is the sole communication channel)
- Public-facing customer portal or storefront
- Multi-user roles/permissions (single admin user)
- Automated drip sequences or scheduled campaign sends
- Complex reporting beyond dashboard widgets
- Integration with any service beyond Stripe, Twilio, and the website form