synchub / laravel-synchub
Synchronization infrastructure for Laravel applications
Requires
- php: ^8.2
- illuminate/support: ^11.0|^12.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0
- phpunit/phpunit: ^10.5|^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
A Laravel package for building and managing reliable data synchronization processes between your application and external services.
The package provides a structured synchronization pipeline with support for individual and batch synchronization, process tracking, dependencies, retries, logging, and optional initial source payloads.
Features
- Individual synchronization — Synchronize a single source entity.
- Batch synchronization — Process multiple source entities using Laravel job batches.
- Optional initial payload — Provide the source data when starting a synchronization and avoid an additional source lookup.
- Fallback source loading — When no initial payload is provided, the package loads the source data through the configured
SourceGateway. - Process tracking — Track synchronization status, current step, timestamps, payloads, responses, and errors.
- Retry and rerun support — Failed and completed processes can be executed again when allowed.
- Dependencies — Synchronizations can depend on other synchronization processes.
- Triggered processes — A synchronization can trigger other synchronization processes.
- Logging — Execution steps and relevant payloads can be recorded for monitoring and debugging.
- Extensible architecture — Source and target integrations can be implemented through dedicated gateways and contracts.
Installation
You can install the package via Composer:
composer require synchub/laravel-synchub
Configuration
Publish the SyncHub configuration file:
php artisan vendor:publish --tag=laravel-synchub-config
This will create:
config/synchub.php
The configuration file allows you to customize the package behavior, including route configuration.
Migrations
SyncHub automatically loads its migrations. No migration publishing is required. After installing the package, simply run:
php artisan migrate
Dashboard Views
SyncHub includes a web dashboard for monitoring synchronization processes, including status, logs, payloads, dependencies and related processes.
The dashboard views are loaded automatically by the package. If you want to customize them, publish the views:
php artisan vendor:publish --tag=synchub-views
The views will be published to:
resources/views/vendor/synchub/
You can then customize the published Blade files without modifying the package source code.
Dashboard Routes
The SyncHub web and API routes are loaded automatically by the package. Routes can be enabled or disabled through:
'routes' => [ 'enabled' => true, ],
in config/synchub.php.
When enabled, the package registers the routes required by the synchronization API and dashboard. No route publishing or manual route registration is required.
Dashboard Access
After installation, migrations and configuration, the dashboard can be accessed through the route registered by SyncHub.
For production applications, it is recommended to protect the dashboard with your application's authentication or authorization middleware.
Registering a Sync Context
Before executing a synchronization, you must register the context in the SyncHub registry.
The registration creates the relationship between the identifier used by the synchronization entry point and the corresponding SyncContext and SourceIdentityResolver classes.
Overview Flow
1. Create the context
↓
2. Register the context
↓
3. Use the identifier
↓
4. Start synchronization
1. Generate a Context
Use the make:synchub-context command with the --identifier option:
php artisan make:synchub-context Customer --identifier=customers
This command creates the synchronization components for the Customer context in app/Contexts/Customer/:
app/Contexts/Customer/
├── CustomerSyncContext.php
├── CustomerSourceGateway.php
├── CustomerTargetGateway.php
├── CustomerSourceIdentityResolver.php
├── CustomerSyncMapper.php
├── CustomerSyncValidator.php
├── CustomerSyncDependencyChecker.php
└── CustomerAfterSyncHandler.php
The --identifier option defines the name (customers) that will be used to reference this context from routes, commands, jobs, or other synchronization entry points.
customers
↓
CustomerSyncContext
↓
Synchronization pipeline
2. Register the Context
After creating the context, register it using the Sync facade:
use Synchub\LaravelSynchub\Domain\Sync\Facades\Sync; use App\Contexts\Customer\CustomerSyncContext; use App\Contexts\Customer\CustomerSourceIdentityResolver; Sync::register( 'customers', CustomerSyncContext::class, CustomerSourceIdentityResolver::class, );
Arguments Breakdown
| Argument | Description |
|---|---|
'customers' |
Unique identifier used to reference the synchronization context in endpoints and commands. |
CustomerSyncContext::class |
Class responsible for defining the synchronization context and dependencies. |
CustomerSourceIdentityResolver::class |
Class responsible for resolving the source entity identity. |
Context Identifier Resolution
The identifier is the key used by SyncHub to resolve the registered context.
When a route or command references "customers", SyncHub internally resolves it through the registry:
"customers"
│
▼
SyncRegistry
│
├── SyncContext
│ └── CustomerSyncContext
│
└── SourceIdentityResolver
└── CustomerSourceIdentityResolver
If the identifier has not been registered, SyncHub will throw an exception:
Sync context [customers] not registered.
Where to Register
Context registrations are stored in-memory at runtime (not persisted in a database or config file). Therefore, registrations must occur on every application boot before any synchronization is executed.
A Service Provider is the recommended place for registration:
namespace App\Providers; use Illuminate\Support\ServiceProvider; use Synchub\LaravelSynchub\Domain\Sync\Facades\Sync; use App\Contexts\Customer\CustomerSyncContext; use App\Contexts\Customer\CustomerSourceIdentityResolver; class SyncServiceProvider extends ServiceProvider { public function boot(): void { Sync::register( 'customers', CustomerSyncContext::class, CustomerSourceIdentityResolver::class, ); } }
Once registered, the identifier can be used by your synchronization routes, commands, jobs, or other entry points:
route/command
│
│ "customers"
▼
SyncRegistry
│
▼
CustomerSyncContext
│
▼
Sync pipeline
Artisan Commands
SyncHub provides Artisan commands to generate synchronization contexts and their components.
Generate a complete synchronization context
Create a complete synchronization context with an explicit identifier:
php artisan make:synchub-context Customer --identifier=customers
The command also supports nested contexts:
php artisan make:synchub-context Sales/Customer --identifier=customers
If files already exist, the command keeps the existing files by default. You can overwrite existing files using:
php artisan make:synchub-context Customer --identifier=customers --force
Generate individual synchronization components
Individual components can also be generated using:
php artisan make:synchub {type} {name}
Available component types:
contextidentity-resolvermappervalidatordependencyafter-sync
Examples:
php artisan make:synchub context Customer php artisan make:synchub identity-resolver Customer php artisan make:synchub mapper Customer php artisan make:synchub validator Customer php artisan make:synchub dependency Customer php artisan make:synchub after-sync Customer
Use --force to overwrite existing files:
php artisan make:synchub mapper Customer --force
Recommended Setup
For a new synchronization, the simplest approach is to generate the complete context:
php artisan make:synchub-context Customer --identifier=customers
Then implement the generated components according to the requirements of the integration. A typical context may contain:
app/
└── Contexts/
└── Customer/
├── CustomerSyncContext.php
├── CustomerSourceGateway.php
├── CustomerTargetGateway.php
├── CustomerMapper.php
├── CustomerValidator.php
├── CustomerDependencyChecker.php
├── CustomerAfterSyncHandler.php
└── CustomerSourceIdentityResolver.php
Not all components are required for every synchronization. A simple synchronization may only require:
CustomerSyncContextCustomerSourceGatewayCustomerTargetGateway
While a more complex integration may additionally use:
CustomerSourceIdentityResolverCustomerMapperCustomerValidatorCustomerDependencyCheckerCustomerAfterSyncHandler
The generated classes are starting points. Their implementations should contain the application-specific integration logic for the source system, target system, validation, mapping, dependencies, and post-synchronization behavior.
Concepts
A synchronization is identified by a context and a source.
- The context (identifier) identifies the synchronization definition.
- The source identifies the entity being synchronized.
For example:
context: customers source: 123
The package resolves the source identity through the configured identity resolver and then executes the synchronization pipeline.
Synchronization Contracts
Each synchronization context is composed of a set of contracts that define how the source data is loaded, validated, mapped, sent to the target service, persisted, and optionally followed by additional actions.
The main contracts available to a synchronization context are:
SourceGatewayTargetGatewayMappingRepositorySyncMapperSyncValidatorSyncDependencyCheckerAfterSyncHandler
Not every synchronization needs to implement all contracts. The SyncMapper, SyncValidator, SyncDependencyChecker, and AfterSyncHandler are optional.
SyncContext
The SyncContext is the object that connects the synchronization implementation with the SyncHub pipeline.
use Synchub\LaravelSynchub\Domain\Sync\Context\SyncContext; final class CustomerSyncContext extends SyncContext { public function __construct() { parent::__construct( source: new CustomerSourceGateway(), target: new CustomerTargetGateway(), repository: new CustomerMappingRepository(), mapper: new CustomerSyncMapper(), validator: new CustomerSyncValidator(), ); } }
The required dependencies are:
public function __construct( public SourceGateway $source, public TargetGateway $target, public MappingRepository $repository, public ?SyncMapper $mapper = null, public ?SyncValidator $validator = null, public ?SyncDependencyChecker $dependencyChecker = null, public ?AfterSyncHandler $afterSync = null, )
Therefore, a minimal synchronization requires:
- A
SourceGateway - A
TargetGateway - A
MappingRepository
The remaining contracts can be added according to the requirements of the synchronization.
SourceGateway
The SourceGateway is responsible for retrieving the source data and storing the target identifier associated with the source entity.
interface SourceGateway { public function find( SourceIdentity $identity, ): ?array; public function saveTargetId( SourceIdentity $identity, TargetIdentity $targetIdentity, ): void; }
Example:
final class CustomerSourceGateway implements SourceGateway { public function find( SourceIdentity $identity, ): ?array { return Customer::query() ->whereKey($identity->value()) ->first()?->toArray(); } public function saveTargetId( SourceIdentity $identity, TargetIdentity $targetIdentity, ): void { Customer::query() ->whereKey($identity->value()) ->update([ 'target_id' => $targetIdentity->value(), ]); } }
Initial source payload
The source lookup is optional when the synchronization is started with an initial payload. For example:
{
"source": 123,
"data": {
"id": 123,
"name": "John",
"email": "john@example.com"
}
}
When data is provided, the synchronization can use this payload instead of calling $sourceGateway->find($identity);. When no initial payload is provided, the existing source loading flow is used.
TargetGateway
The TargetGateway is responsible for creating or updating the entity in the external target system.
interface TargetGateway { public function create( SyncData $data ): SyncResultData; public function update( TargetIdentity $identity, SyncData $data ): SyncResultData; }
The gateway provides two operations:
create()— creates a new entity in the target system.update()— updates an existing entity using itsTargetIdentity.
Example:
final class CustomerTargetGateway implements TargetGateway { public function create( SyncData $data ): SyncResultData { $response = Http::post( 'https://api.example.com/customers', $data->toArray(), ); return SyncResultData::fromResponse( $response->json(), ); } public function update( TargetIdentity $identity, SyncData $data ): SyncResultData { $response = Http::put( "https://api.example.com/customers/{$identity->value()}", $data->toArray(), ); return SyncResultData::fromResponse( $response->json(), ); } }
MappingRepository
The MappingRepository stores the relationship between the source entity and the corresponding target entity.
interface MappingRepository { public function find( string $context, SourceIdentity $identity, ): ?MappingEntity; public function create( string $context, SourceIdentity $identity, SyncResultData $response, SyncData $mappedData, ): MappingEntity; public function update( MappingEntity $mapping, SyncResultData $response, SyncData $mappedData, ): MappingEntity; }
Conceptually:
Source Identity
│
▼
MappingRepository
│
├── No mapping ──► TargetGateway::create()
│
└── Mapping exists ──► TargetGateway::update()
SyncMapper
The SyncMapper transforms source data into the structure expected by the target system.
interface SyncMapper { public function map( array $data, array $lastPayload, ): SyncData; }
Example:
final class CustomerSyncMapper implements SyncMapper { public function map( array $data, array $lastPayload, ): SyncData { return new SyncData([ 'name' => $data['name'], 'email' => $data['email'], ]); } }
The second argument, $lastPayload, contains the previous mapped payload when available, allowing the mapper to account for historical data.
SyncValidator
The SyncValidator validates the source data before it continues through the synchronization pipeline.
interface SyncValidator { /** * @return array $errors */ public function validate( array $data, array $lastPayload, ): array; }
Example:
final class CustomerSyncValidator implements SyncValidator { public function validate( array $data, array $lastPayload, ): array { $validator = Validator::make( $data, [ 'name' => ['required', 'string'], 'email' => ['required', 'email'], ], ); return $validator->errors()->toArray(); } }
SyncDependencyChecker
The SyncDependencyChecker allows a synchronization to declare or resolve dependencies based on its source data.
interface SyncDependencyChecker { /** * @return SyncDependencyData[] */ public function check(array $data): array; }
This contract is optional and should be used when a synchronization cannot execute until another synchronization completes (e.g., a Product depending on its Category).
AfterSyncHandler
The AfterSyncHandler allows application-specific logic to run after the synchronization pipeline completes. Unlike other contracts, it is an abstract class.
abstract class AfterSyncHandler { abstract public function handle( SyncProcessEntity $process, ): void; }
Example:
final class CustomerAfterSyncHandler extends AfterSyncHandler { public function handle( SyncProcessEntity $process, ): void { event( new CustomerSynchronized( $process->sourceIdentity, ) ); } }
SourceIdentityResolver
The SourceIdentityResolver converts the value received by the synchronization endpoint into a SourceIdentity.
interface SourceIdentityResolver { public function resolve( mixed $source, ): SourceIdentity; /** * @param array<mixed> $sources * @return array<SourceIdentity> */ public function resolveMany( array $sources, ): array; }
Endpoints
Individual Synchronization
An individual synchronization can be started through the synchronization endpoint:
POST /synchub/{context}/sync
Where {context} is the registered identifier (e.g., customers).
Example request body:
{
"source": 123
}
Providing the initial payload:
{
"source": 123,
"data": {
"id": 123,
"name": "John",
"email": "john@example.com"
}
}
Force synchronization:
{
"source": 123,
"data": {
"id": 123,
"name": "John"
},
"force": true
}
Batch Synchronization
Multiple source entities can be synchronized using the batch endpoint:
POST /synchub/{context}/batch
The endpoint accepts two mutually exclusive formats: ids (a plain list of identifiers) or items (identifiers with optional initial payloads).
Plain list of ids
When you only have identifiers, send them directly. Each value must be an integer or a non-empty string and is passed as-is to the context's SourceIdentityResolver::resolveMany().
{
"ids": [123, 456, "ABC-789"],
"force": true
}
Items
Use items when you need to send an initial payload, or when the source is not a simple scalar (for example a composite identity).
{
"items": [
{
"source": 123
},
{
"source": 456
},
{
"source": 789
}
],
"force": true
}
Initial payloads in batch synchronization:
{
"items": [
{
"source": 123,
"data": {
"id": 123,
"name": "John"
}
},
{
"source": 789,
"data": {
"id": 123,
"name": "Mary"
}
}
],
"force": true
}
Behavior
- Duplicates are removed by resolved identity before dispatching, so the same entity is synchronized only once per request. With
items, when several occurrences providedata, the last one wins. - Invalid entries return
422with the offending index (ids.1,items.2.source), including errors raised by your resolver. Nothing is dispatched when any entry is invalid. - Limits come from the
batchconfig section:
'batch' => [ 'max_items' => env('SYNCHUB_BATCH_MAX_ITEMS', 1000), // max entries per request 'chunk_size' => env('SYNCHUB_BATCH_CHUNK_SIZE', 100), // identities per queued job ],
Example response:
{
"status": "queued",
"batch_id": "9d1f0c3e-...",
"total": 3,
"received": 4,
"duplicates_removed": 1
}
batch_id is the Laravel job batch id, which can be inspected with Bus::findBatch($batchId).
Using Your Own Routes and Controllers
The package controllers are optional. An application can expose its own URLs, validation, authorization and responses, and still delegate the synchronization to the package through the Application handlers:
| Handler | Input | Returns |
|---|---|---|
StartSyncHandler |
StartSync |
SyncProcessEntity (the created process, with id) |
StartBatchSyncHandler |
StartBatchSync |
Illuminate\Bus\Batch |
1. Choose where the routes live. Either keep source: application and declare the routes in the published file (routes/synchub-api.php, loaded with the synchub.routes.api.middleware), or disable the package API routes and declare them in your own route files:
SYNCHUB_API_ROUTES_ENABLED=false
2. Write the controller. Resolve the identity with the context's resolver and call the handler:
use Synchub\LaravelSynchub\Application\Sync\Commands\StartBatchSync; use Synchub\LaravelSynchub\Application\Sync\Commands\StartSync; use Synchub\LaravelSynchub\Application\Sync\Handlers\StartBatchSyncHandler; use Synchub\LaravelSynchub\Application\Sync\Handlers\StartSyncHandler; use Synchub\LaravelSynchub\Domain\Sync\Facades\Sync; class CustomerSyncController extends Controller { public function __construct( private StartSyncHandler $startSync, private StartBatchSyncHandler $startBatchSync, ) {} // POST /api/customers/{customerId}/sync public function sync(Request $request, string $customerId) { $process = $this->startSync->handle(new StartSync( context: 'customers', identity: Sync::identityResolver('customers')->resolve($customerId), force: $request->boolean('force'), sourcePayload: $request->input('data'), )); return response()->json(['process_id' => $process->id], 202); } // POST /api/customers/sync { "ids": [1, 2, 3] } public function batch(Request $request) { $identities = collect(Sync::identityResolver('customers')->resolveMany($request->input('ids'))) ->unique(fn ($identity) => $identity->key()) ->values() ->all(); $batch = $this->startBatchSync->handle(new StartBatchSync( context: 'customers', identities: $identities, force: $request->boolean('force'), chunkSize: (int) config('synchub.batch.chunk_size', 100), )); return response()->json(['batch_id' => $batch->id], 202); } }
3. Register the routes with the middleware your application needs:
Route::prefix('api/customers') ->middleware(['auth:sanctum', 'throttle:60,1']) ->controller(CustomerSyncController::class) ->group(function () { Route::post('sync', 'batch'); Route::post('{customerId}/sync', 'sync'); });
Things to keep in mind:
- Validate input before resolving:
SourceIdentityonly accepts integers and non-empty strings and throwsInvalidArgumentExceptionotherwise. SourceIdentityis type-strict:10and"10"are different identities. Normalize ids (for example, URL segments are always strings) so the same entity always produces the same key.- Remove duplicates by
$identity->key()before a batch; otherwise a later process makes the earlier one obsolete. - The process state can be read from the
SyncProcessmodel (context+source_key) to build status endpoints.
Synchronization Process & Monitoring
Each synchronization creates a process that tracks its execution state (pending, processing, waiting_dependency, success, failed, error, obsolete), current step, initial payloads, target responses, errors, and execution duration.
Pipeline Architecture
Request
↓
StartSync / StartBatchSync
↓
Create Sync Process
↓
Synchronization Pipeline
↓
Load Source Data (Payload or SourceGateway::find)
↓
SyncValidator
↓
SyncMapper
↓
MappingRepository (Check mapping)
├── No mapping ──► TargetGateway::create()
└── Mapping exists ──► TargetGateway::update()
↓
Save Target Identity
↓
AfterSyncHandler
↓
Complete Process
Contributing
Please read CONTRIBUTING.md for information about the contribution process and development guidelines.
License
This project is licensed under the MIT License.