Module Structure

Understanding how modules are organized helps you build maintainable and consistent integrations.

Directory Layout

A complete module contains backend (Laravel) and frontend (React/Next.js) code:

modules/YourModule/
├── backend/                    # Laravel Backend
│   ├── App/
│   │   ├── Http/
│   │   │   ├── Controllers/    # API controllers
│   │   │   └── Requests/       # Form requests
│   │   ├── Models/             # Eloquent models
│   │   ├── Services/           # Business logic
│   │   ├── Jobs/               # Queue jobs
│   │   └── Resources/          # API resources
│   ├── config/                 # Module configuration
│   ├── routes/
│   │   ├── api.php             # API routes
│   │   └── webhooks.php        # Webhook routes
│   ├── Database/
│   │   └── Migrations/         # Database migrations
│   ├── tests/
│   │   ├── Feature/            # API/integration tests
│   │   ├── Unit/               # Unit tests
│   │   └── Pest.php            # Test configuration
│   └── YourModuleServiceProvider.php
│
├── frontend/                   # React/Next.js Frontend
│   ├── index.ts                # Public exports
│   ├── components/             # React components
│   ├── pages/                  # Module pages
│   ├── hooks/                  # React hooks
│   ├── types/                  # TypeScript types
│   └── lib/                    # Utilities
│
├── module.json                 # Module manifest (REQUIRED)
└── README.md

Service Provider

The main entry point for your module. Registers services, config, migrations, and event listeners:

namespace Modules\YourModule;

use Illuminate\Support\ServiceProvider;

class YourModuleServiceProvider extends ServiceProvider
{
    protected $moduleName = 'YourModule';
    protected $moduleNameLower = 'yourmodule';

    public function boot()
    {
        $this->mergeConfigFrom(
            module_path($this->moduleName, 'config/config.php'),
            $this->moduleNameLower
        );

        $this->loadMigrationsFrom(
            module_path($this->moduleName, 'Database/Migrations')
        );

        $this->registerEvents();
    }

    public function register()
    {
        $this->app->register(RouteServiceProvider::class);

        $this->app->singleton(YourService::class, function ($app) {
            return new YourService();
        });
    }

    protected function registerEvents()
    {
        Event::listen(OrderCreated::class, HandleOrderCreated::class);
    }
}

Routes

Define HTTP endpoints in routes/api.php:

use Illuminate\Support\Facades\Route;
use Modules\YourModule\App\Http\Controllers\YourController;

// Authenticated tenant routes
Route::group([
    'prefix' => 'integrations/yourmodule',
    'middleware' => ['auth:api', 'tenant']
], function () {
    Route::post('/connect', [YourController::class, 'connect']);
    Route::post('/disconnect', [YourController::class, 'disconnect']);
    Route::get('/status', [YourController::class, 'status']);
    Route::post('/sync', [YourController::class, 'sync']);
});

// Webhook routes (no auth, tenant resolved from payload)
Route::group([
    'prefix' => 'webhooks/yourmodule',
    'middleware' => ['tenant']
], function () {
    Route::post('/{topic}', [YourController::class, 'webhook']);
});

Models

Eloquent models for module-specific data in App/Models/:

namespace Modules\StoreShopify\App\Models;

use Illuminate\Database\Eloquent\Model;

class ShopifyConnection extends Model
{
    protected $fillable = [
        'tenant_id', 'shop_domain', 'access_token',
        'is_active', 'last_sync_at',
    ];

    protected $casts = [
        'is_active' => 'boolean',
        'last_sync_at' => 'datetime',
    ];
}

Services

Business logic and external API integration in App/Services/:

namespace Modules\StoreShopify\App\Services;

class ShopifyService
{
    protected $client;

    public function __construct()
    {
        $this->client = new \GuzzleHttp\Client();
    }

    public function fetchOrders($since = null)
    {
        $shopDomain = ModuleManager::getSetting('store-shopify', 'shop_domain');
        $accessToken = ModuleManager::getSetting('store-shopify', 'access_token');

        $response = $this->client->get(
            "https://{$shopDomain}/admin/api/2024-01/orders.json",
            [
                'headers' => ['X-Shopify-Access-Token' => $accessToken],
                'query' => ['status' => 'any'],
            ]
        );

        return json_decode($response->getBody(), true);
    }
}

Queue Jobs

Background processing in App/Jobs/:

namespace Modules\StoreShopify\App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;

class SyncShopifyOrders implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

    public function __construct(
        protected string $tenantId,
        protected ?Carbon $since = null,
    ) {}

    public function handle(ShopifyService $shopifyService)
    {
        tenancy()->initialize($this->tenantId);

        $orders = $shopifyService->fetchOrders($this->since);

        foreach ($orders['orders'] as $orderData) {
            OrderService::createFromShopify($orderData);
        }
    }
}

Events & Listeners

Custom events in App/Events/ and listeners in App/Listeners/:

// App/Events/ShopifyOrderSynced.php
class ShopifyOrderSynced
{
    public function __construct(
        public Order $order,
        public array $shopifyData,
    ) {}
}

// App/Listeners/NotifyOrderSynced.php
class NotifyOrderSynced
{
    public function handle(ShopifyOrderSynced $event)
    {
        Log::info('Shopify order synced', [
            'order_id' => $event->order->id,
            'shopify_id' => $event->shopifyData['id'],
        ]);
    }
}

Migrations

Database schema in Database/Migrations/:

return new class extends Migration
{
    public function up()
    {
        Schema::create('shopify_connections', function (Blueprint $table) {
            $table->id();
            $table->foreignId('tenant_id')->constrained()->onDelete('cascade');
            $table->string('shop_domain')->unique();
            $table->text('access_token')->nullable();
            $table->boolean('is_active')->default(true);
            $table->timestamp('last_sync_at')->nullable();
            $table->timestamps();

            $table->index(['tenant_id', 'shop_domain']);
        });
    }

    public function down()
    {
        Schema::dropIfExists('shopify_connections');
    }
};

File Naming Conventions

Type Convention Example
Models StudlyCase ShopifyConnection.php
Controllers StudlyCaseController ShopifyAuthController.php
Services StudlyCaseService ShopifyService.php
Jobs VerbNoun SyncShopifyOrders.php
Events NounVerbed OrderSynced.php
Listeners VerbNoun HandleOrderSynced.php

Related