DEV Community

Cover image for I Built a Zuora Workflow Manager with Laravel 12 + Filament (Full Tech Stack Walkthrough)
Davide
Davide

Posted on

I Built a Zuora Workflow Manager with Laravel 12 + Filament (Full Tech Stack Walkthrough)

TL;DR

Built an open-source Zuora Workflow Manager to automate workflow synchronization with these features:

  • 🔄 Automatic sync with configurable schedules (hourly by default)
  • 📊 Filament admin panel with rich visualization
  • ⚙️ Background jobs with retry logic (3x, 60s backoff)
  • 🔐 OAuth 2.0 with 1-hour token caching
  • 🎯 Multi-tenant with per-customer credentials
  • 📋 Task extraction from workflow JSON
  • 🔒 Encrypted settings with custom Cast
  • 📈 Interactive graph visualization
  • 🎨 Modern UI with screenshots

Link Repository

📚 Full Documentation | 🚀 Latest Release | 🐛 Issues


Screenshots 📸

Before diving into code, here's what the app looks like:

Login Screen (with Google OAuth support)
Login

Dashboard Overview
Dashboard

Workflows List (search, filter, multi-tenant)
Workflows List

Workflow Detail (graph view, tasks, JSON export)
Workflow Detail


The Problem 🎯

Managing Zuora workflows manually across multiple clients is a nightmare:

  • Manual synchronization is time-consuming and error-prone
  • No visibility into workflow structure without diving into JSON
  • OAuth token management requires constant attention
  • Failed API calls mean stale data
  • Multi-tenant complexity with separate credentials per client

I needed a solution that handles all this automatically while providing a beautiful admin interface.


Tech Stack 🛠️

Backend:        Laravel 12.45.1 + PHP 8.4
Admin Panel:    Filament 4.2 + Filament Shield
Database:       MariaDB 11.4
Queue:          Database Driver (with retry logic)
Cache:          Redis 7.0
DevOps:         Lando (Docker-based)
Frontend:       Livewire + Tailwind CSS 4
Visualization:  @joint/layout-directed-graph
Settings:       Spatie Laravel Settings
Jobs Monitor:   Moox Jobs
Testing:        PHPUnit + Parallel execution
Enter fullscreen mode Exit fullscreen mode

The Problem 🎯

Managing Zuora workflows manually across multiple clients is a nightmare:

  • Manual synchronization is time-consuming and error-prone
  • No visibility into workflow structure without diving into JSON
  • OAuth token management requires constant attention
  • Failed API calls mean stale data
  • Multi-tenant complexity with separate credentials per client

I needed a solution that handles all this automatically while providing a beautiful admin interface.


Tech Stack 🛠️

Backend:        Laravel 12 + PHP 8.4
Admin Panel:    Filament 4.2 + Filament Shield
Database:       MariaDB 11.4
Queue:          Database Driver (with retry logic)
Cache:          Redis 7.0
DevOps:         Lando (Docker-based)
Frontend:       Livewire + Tailwind CSS 4
Visualization:  @joint/layout-directed-graph
Settings:       Spatie Laravel Settings
Jobs Monitor:   Moox Jobs
Testing:        PHPUnit + Parallel execution
Enter fullscreen mode Exit fullscreen mode

Key Features ✨

🔄 Automatic Synchronization

Workflows sync automatically on a configurable schedule:

// routes/console.php
Schedule::command('app:sync-workflows --all')
    ->hourly()  // Configurable: ->everyFiveMinutes(), ->daily(), etc.
    ->name('sync-customer-workflows');
Enter fullscreen mode Exit fullscreen mode

Each sync job:

  • Fetches workflows from Zuora REST API (automatic pagination)
  • Downloads complete workflow JSON
  • Extracts and stores tasks
  • Updates database atomically
  • Retries on failure (3 attempts, 60s backoff)

📊 Rich Admin Dashboard

Built with Filament 4.2, providing:

  • Search & Filters: Find workflows by name, state, customer
  • Workflow Details: Multi-tab view with graph, JSON, and tasks
  • Task Manager: Filter by action type, priority, state
  • JSON Operations: Copy or download workflow JSON
  • Real-time Updates: Livewire reactive components

⚙️ Background Job Processing

All sync operations run in background queues:

// Dispatch job
SyncCustomerWorkflows::dispatch($customer);

// Job configuration
public $tries = 3;
public $backoff = 60;  // Exponential backoff
public $timeout = 300;
Enter fullscreen mode Exit fullscreen mode

Monitoring: Moox Jobs integration provides real-time tracking:

  • Running jobs
  • Waiting jobs
  • Failed jobs (with retry capability)
  • Job batches

🔐 OAuth 2.0 with Smart Caching

Token management with 1-hour caching reduces API calls by 90%:

// ZuoraService.php
protected function getAccessToken(Customer $customer): string
{
    return Cache::remember(
        "zuora_token_{$customer->id}",
        3600,  // 1 hour
        fn() => $this->requestNewToken($customer)
    );
}
Enter fullscreen mode Exit fullscreen mode

🏢 Multi-Tenant Architecture

Each customer has isolated credentials stored in the database:

// Database schema
customers:
  - id
  - name
  - client_id      (Zuora OAuth)
  - client_secret  (Zuora OAuth, encrypted)
  - base_url       (Production/Test/Sandbox)
Enter fullscreen mode Exit fullscreen mode

No shared credentials = better security and isolation.

📋 Automatic Task Extraction

Tasks are parsed from workflow JSON and stored separately:

// Workflow.php
public function syncTasksFromJson(): void
{
    $tasks = $this->extractTasksFromWorkflow($this->workflow_json);

    // Atomic update
    $this->tasks()->delete();
    $this->tasks()->createMany($tasks);
}
Enter fullscreen mode Exit fullscreen mode

Task filters:

  • Action Type: Email, Export, SOAP, REST, etc.
  • Priority: High, Medium, Low
  • State: Active, Inactive, Pending

🔒 Encrypted Settings

Sensitive configuration uses custom EncryptedCast:

// GeneralSettings.php (camelCase properties)
use App\Casts\EncryptedCast;

class GeneralSettings extends Settings
{
    public string $oauthGoogleClientSecret;

    protected $casts = [
        'oauthGoogleClientSecret' => EncryptedCast::class,
    ];
}
Enter fullscreen mode Exit fullscreen mode

Data encrypted at rest using Laravel's Crypt facade.


Architecture Deep Dive 🏗️

Service Layer Pattern

Clean separation of concerns:

ZuoraService

class ZuoraService
{
    public function getWorkflows(Customer $customer, int $page = 1): array
    public function exportWorkflow(Customer $customer, string $workflowId): array
    protected function getAccessToken(Customer $customer): string
}
Enter fullscreen mode Exit fullscreen mode

WorkflowSyncService

class WorkflowSyncService
{
    public function syncCustomerWorkflows(Customer $customer): void
    protected function fetchAndSaveWorkflows(Customer $customer): void
    protected function extractTasks(Workflow $workflow): void
}
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Easy to test (mock services)
  • Reusable across controllers and jobs
  • Clear responsibilities

Queue Processing Flow

┌─────────────────┐
│   User Action   │
│   (UI or CLI)   │
└────────┬────────┘
         │
         ▼
┌─────────────────────────────┐
│ SyncCustomerWorkflows Job   │
│ → Dispatched to Queue       │
└────────┬────────────────────┘
         │
         ▼
┌─────────────────────────────┐
│   Queue Worker Process      │
│   (lando queue / cron)      │
└────────┬────────────────────┘
         │
         ▼
┌─────────────────────────────┐
│  WorkflowSyncService        │
│  ├─ Fetch from API          │
│  ├─ Download JSON           │
│  ├─ Save workflows          │
│  └─ Extract tasks           │
└────────┬────────────────────┘
         │
         ▼
┌─────────────────────────────┐
│   Database Storage          │
│   (workflows + tasks)       │
└────────┬────────────────────┘
         │
         ▼
┌─────────────────────────────┐
│   Filament UI Display       │
└─────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Database Schema 📊

Workflows Table

CREATE TABLE workflows (
    id BIGINT UNSIGNED PRIMARY KEY,
    customer_id BIGINT UNSIGNED NOT NULL,
    zuora_id VARCHAR(255) NOT NULL UNIQUE,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    state VARCHAR(255),
    workflow_json JSON,
    last_synced_at TIMESTAMP,

    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE,
    INDEX idx_customer_id (customer_id),
    INDEX idx_zuora_id (zuora_id),
    INDEX idx_state (state)
);
Enter fullscreen mode Exit fullscreen mode

Tasks Table

CREATE TABLE tasks (
    id BIGINT UNSIGNED PRIMARY KEY,
    workflow_id BIGINT UNSIGNED NOT NULL,
    zuora_id VARCHAR(255) NOT NULL,
    name VARCHAR(255) NOT NULL,
    task_type VARCHAR(255),
    action_type VARCHAR(255),
    data JSON,

    FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
    INDEX idx_workflow_id (workflow_id),
    INDEX idx_task_type (task_type)
);
Enter fullscreen mode Exit fullscreen mode

Foreign keys with CASCADE ensure data integrity when customers or workflows are deleted.


Code Highlights 💡

1. Job Retry Logic with Exponential Backoff

// Jobs/SyncCustomerWorkflows.php
class SyncCustomerWorkflows implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public $tries = 3;
    public $backoff = 60;  // 60, 120, 240 seconds
    public $timeout = 300;

    public function handle(WorkflowSyncService $syncService): void
    {
        try {
            $syncService->syncCustomerWorkflows($this->customer);
        } catch (Exception $e) {
            Log::error("Workflow sync failed for customer {$this->customer->id}: {$e->getMessage()}");
            throw $e;  // Trigger retry
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

2. OAuth Token Caching

// Services/ZuoraService.php
protected function getAccessToken(Customer $customer): string
{
    $cacheKey = "zuora_token_{$customer->id}";

    return Cache::remember($cacheKey, 3600, function() use ($customer) {
        $response = Http::asForm()->post("{$customer->base_url}/oauth/token", [
            'client_id' => $customer->client_id,
            'client_secret' => $customer->client_secret,
            'grant_type' => 'client_credentials',
        ]);

        return $response->json('access_token');
    });
}
Enter fullscreen mode Exit fullscreen mode

3. Automatic Pagination Handling

// Services/WorkflowSyncService.php
protected function fetchAllWorkflows(Customer $customer): Collection
{
    $allWorkflows = collect();
    $page = 1;

    do {
        $response = $this->zuoraService->getWorkflows($customer, $page);
        $workflows = collect($response['data']);

        $allWorkflows = $allWorkflows->merge($workflows);
        $page++;

    } while ($workflows->isNotEmpty() && $workflows->count() >= 50);

    return $allWorkflows;
}
Enter fullscreen mode Exit fullscreen mode

4. Custom Encrypted Cast

// Casts/EncryptedCast.php
namespace App\Casts;

use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
use Illuminate\Support\Facades\Crypt;

class EncryptedCast implements CastsAttributes
{
    public function get($model, string $key, $value, array $attributes)
    {
        return $value ? Crypt::decryptString($value) : null;
    }

    public function set($model, string $key, $value, array $attributes)
    {
        return $value ? Crypt::encryptString($value) : null;
    }
}
Enter fullscreen mode Exit fullscreen mode

5. Task Extraction from JSON

// Models/Workflow.php
public function syncTasksFromJson(): void
{
    if (!$this->workflow_json) {
        return;
    }

    $tasks = $this->extractTasksFromWorkflow($this->workflow_json);

    // Atomic update
    DB::transaction(function() use ($tasks) {
        $this->tasks()->delete();
        $this->tasks()->createMany($tasks);
    });
}

protected function extractTasksFromWorkflow(array $workflowData): array
{
    $tasks = [];
    $workflowTasks = $workflowData['tasks'] ?? [];

    foreach ($workflowTasks as $task) {
        $tasks[] = [
            'zuora_id' => $task['id'],
            'name' => $task['name'],
            'task_type' => $task['task_type'] ?? null,
            'action_type' => $task['action_type'] ?? null,
            'data' => $task,
        ];
    }

    return $tasks;
}
Enter fullscreen mode Exit fullscreen mode

Development Setup 🚀

Prerequisites

Quick Start

# Clone repository
git clone https://github.com/FrancoStino/zuora-workflows.git
cd zuora-workflows

# Start Lando (automatically runs composer install)
lando start

# Setup environment
cp .env.example .env
lando artisan key:generate

# Run migrations
lando artisan migrate

# Install frontend dependencies
lando yarn install
yarn run build

# Start queue worker (for background jobs)
lando queue

# Start scheduler (for automatic sync)
lando schedule
Enter fullscreen mode Exit fullscreen mode

Access the app: https://zuora-workflows.lndo.site

Lando Commands

lando artisan [command]     # Run artisan commands
lando composer [command]    # Run composer
lando yarn [command]        # Run yarn
lando queue                 # Start queue worker
lando schedule              # Start scheduler
lando test                  # Run tests
lando logs -f               # View logs
lando mariadb               # Database CLI
Enter fullscreen mode Exit fullscreen mode

Why Lando?

Before Lando: Hours setting up PHP, MySQL, Redis, configuring versions, dealing with platform differences.

With Lando:

lando start
Enter fullscreen mode Exit fullscreen mode

That's it. Consistent environment for everyone.


Testing Strategy ✅

Running Tests

# All tests
lando test

# Specific test file
lando artisan test tests/Feature/SyncWorkflowsTest.php

# With output
lando artisan test -v

# With coverage
lando artisan test --coverage

# Parallel execution
lando artisan test --parallel
Enter fullscreen mode Exit fullscreen mode

Test Structure

tests/
├── Feature/
│   ├── WorkflowSyncTest.php       # Integration tests
│   ├── OAuthTest.php              # OAuth flow tests
│   └── TaskExtractionTest.php     # Task parsing tests
├── Unit/
│   ├── ZuoraServiceTest.php       # Service unit tests
│   └── EncryptedCastTest.php      # Cast tests
└── TestCase.php
Enter fullscreen mode Exit fullscreen mode

Example Test

// tests/Feature/WorkflowSyncTest.php
class WorkflowSyncTest extends TestCase
{
    public function test_workflow_sync_creates_workflows_and_tasks()
    {
        // Arrange
        $customer = Customer::factory()->create();
        Http::fake([
            '*/v1/workflows*' => Http::response([
                'data' => [
                    [
                        'id' => 'wf_123',
                        'name' => 'Test Workflow',
                        'state' => 'Active',
                    ]
                ]
            ]),
            '*/v1/workflows/wf_123/export' => Http::response([
                'tasks' => [
                    ['id' => 'task_1', 'name' => 'Send Email']
                ]
            ]),
        ]);

        // Act
        $this->artisan('app:sync-workflows', ['--customer' => $customer->name]);

        // Assert
        $this->assertDatabaseHas('workflows', [
            'zuora_id' => 'wf_123',
            'name' => 'Test Workflow',
        ]);

        $this->assertDatabaseHas('tasks', [
            'zuora_id' => 'task_1',
            'name' => 'Send Email',
        ]);
    }
}
Enter fullscreen mode Exit fullscreen mode

Deployment Options 🌐

Option A: Sync Queue (Simplest)

Perfect for shared hosting where background processes are limited:

QUEUE_CONNECTION=sync
Enter fullscreen mode Exit fullscreen mode

Pros:

  • No cron setup needed
  • No queue worker required
  • Jobs execute immediately

Cons:

  • Blocks request until job completes
  • Not ideal for large sync operations

Option B: Database Queue + Cron (Recommended)

For proper background processing:

QUEUE_CONNECTION=database
Enter fullscreen mode Exit fullscreen mode

Add cron job:

* * * * * cd /path/to/app && php artisan schedule:run >> /dev/null 2>&1
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Non-blocking UI
  • Automatic retry on failure
  • Job monitoring and tracking

Cons:

  • Requires cron access
  • Slightly more complex setup

Option C: Redis Queue (Production)

For high-performance production environments:

QUEUE_CONNECTION=redis
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
Enter fullscreen mode Exit fullscreen mode

Pros:

  • Fastest queue performance
  • Supports job prioritization
  • Distributed processing

Cons:

  • Requires Redis server
  • Additional infrastructure

📚 Full Deployment Guide


Challenges & Solutions 🔧

Challenge Problem Solution
OAuth Expiry Tokens expire after 1 hour 1-hour cache + automatic refresh
API Pagination Max 50 items per request Automatic loop handling
Failed Jobs Network/timeout issues Retry logic 3x with 60s backoff
Multi-tenant Shared credentials = security risk Per-customer credentials in DB
Task Extraction Complex nested JSON structures Dedicated parsing method + atomic DB update
Sensitive Data Secrets stored in plaintext Custom EncryptedCast with Laravel Crypt
Development Setup "Works on my machine" Lando containerization
Code Consistency Mixed naming conventions Refactored to camelCase

Performance Considerations 📈

Database Optimization

-- Foreign key indexes
INDEX idx_customer_id (customer_id)
INDEX idx_workflow_id (workflow_id)

-- Query optimization indexes
INDEX idx_zuora_id (zuora_id)
INDEX idx_state (state)
INDEX idx_task_type (task_type)

-- Composite indexes for common queries
INDEX idx_customer_state (customer_id, state)
Enter fullscreen mode Exit fullscreen mode

Caching Strategy

Layer TTL Purpose
OAuth Tokens 1 hour Reduce auth API calls by 90%
Settings Until changed Avoid DB reads on every request
Workflow List 5 minutes Optional for high-traffic dashboards

Query Optimization

// BAD: N+1 queries
$workflows = Workflow::all();
foreach ($workflows as $workflow) {
    echo $workflow->customer->name;  // Queries for each workflow
}

// GOOD: Eager loading
$workflows = Workflow::with('customer')->get();
foreach ($workflows as $workflow) {
    echo $workflow->customer->name;  // No additional queries
}
Enter fullscreen mode Exit fullscreen mode

Job Processing

  • Queue workers: Run multiple workers for parallel processing
  • Job batching: Group related jobs for better efficiency
  • Timeout handling: 300s timeout prevents stuck jobs

Monitoring & Debugging 🔍

View Logs

# Real-time logs
lando logs -f

# Specific service (PHP)
lando logs -s appserver -f

# Laravel logs
lando exec appserver tail -f storage/logs/laravel.log

# Filter for errors
lando logs -f | grep -i "error"
Enter fullscreen mode Exit fullscreen mode

Check Queue Status

# Failed jobs
lando artisan queue:failed

# Retry all failed
lando artisan queue:retry all

# Retry specific job
lando artisan queue:retry {job-id}

# Clear failed jobs
lando artisan queue:flush
Enter fullscreen mode Exit fullscreen mode

Database Inspection

# Access MariaDB
lando mariadb

# Check queue
SELECT * FROM jobs;

# Check failed jobs
SELECT * FROM failed_jobs ORDER BY failed_at DESC;

# Check recent workflows
SELECT * FROM workflows ORDER BY last_synced_at DESC LIMIT 10;
Enter fullscreen mode Exit fullscreen mode

Moox Jobs Panel

Access via Filament admin: Jobs → Dashboard

  • Running Jobs: Currently processing
  • Waiting Jobs: In queue
  • Failed Jobs: With error messages and retry button
  • Job Batches: Batch operations tracking

What's Next? 🚀

Roadmap

  • [ ] Webhook Support: Real-time sync when workflows change in Zuora
  • [ ] Advanced Analytics: Workflow execution statistics and trends
  • [ ] Bulk Operations: Update multiple workflows at once
  • [ ] REST API: External integrations and programmatic access
  • [ ] Workflow Builder: Visual editor for creating workflows
  • [ ] Export Formats: PDF, CSV, Excel reports
  • [ ] Notification System: Email/Slack alerts for failed syncs
  • [ ] Audit Logs: Track all workflow changes
  • [ ] Multi-language Support: i18n for admin panel

Contributing

Contributions welcome! See CONTRIBUTING.md

Ways to contribute:

  • 🐛 Report bugs
  • 💡 Suggest features
  • 📖 Improve docs
  • 🔧 Submit PRs
  • ⭐ Star the repo

Lessons Learned 📚

1. Service Layer Pattern = Maintainability

Keeping business logic in service classes made:

  • Testing much easier (mock services)
  • Code reusable across controllers and jobs
  • Debugging simpler (clear responsibilities)

2. Queue Retry Logic is Critical

When dealing with external APIs, failures are inevitable:

  • Network timeouts
  • Rate limiting
  • Server errors

Automatic retry with exponential backoff saved countless manual interventions.

3. Lando Eliminates "Works on My Machine"

Before Lando: Hours debugging environment issues.

After Lando: lando start and everyone has identical setups.

4. Filament is a Game Changer

Building an admin panel from scratch = weeks of work.

With Filament:

  • Beautiful UI out of the box
  • RBAC with Filament Shield
  • Relationship management
  • Form builders and tables

Result: Functional admin panel in hours, not days.

5. Visual Graphs Add Tremendous Value

Users understand complex workflows 10x faster with graphical representation vs. reading JSON.

6. Encrypted Settings = Peace of Mind

Storing OAuth secrets in plaintext = security risk.

Custom EncryptedCast = automatic encryption at rest with zero overhead.

7. Consistent Naming Matters

Refactoring to camelCase:

  • Improved code readability
  • Aligned with Laravel best practices
  • Made IDE autocomplete more reliable

Resources & Links 📎

Documentation

Technologies

Repository


Conclusion

Building Zuora Workflow Manager taught me the importance of:

Choosing the right tools (Laravel + Filament + Lando)

Clean architecture (Service layer pattern)

Reliability (Queue retry logic, caching)

Developer experience (Lando = zero config hell)

User experience (Visual graphs, real-time monitoring)

Code consistency (camelCase refactoring)

The project is open source, production-ready, and well-documented.

Try it out: Clone the repo, run lando start, and you're up in minutes!

Have questions or feedback? Open an issue or start a discussion on GitHub! ⭐


Made with ❤️ for better Zuora workflow management

License: MIT


Comments Section

💬 What would you like to see next? Drop your suggestions in the comments!

📢 Found this helpful? Share it with your team!

🔔 Follow me for more Laravel and PHP content!

Top comments (0)