apptank/horusync
Horusync by Apptank is a Laravel/PHP package that simplifies synchronization workflows, helping applications keep data consistent across services and environments through a clean, framework-friendly API.
Reality check first. apptank/horusync is a single-vendor package (AppTank SAS) with 0 stars, 0 dependents, and no community footprint. Treat the repository — not the README — as the source of truth. Budget an hour to read src/, config/, routes/, and database/migrations/ before you commit to it, and pin an exact version in composer.json ("apptank/horusync": "x.y.z" rather than ^x.y) so a surprise release can't move under you.
Minimal bootstrap:
composer require apptank/horusync
php artisan vendor:publish --provider="AppTank\HorusSync\HorusSyncServiceProvider"
php artisan migrate
Verify what actually got published — config file name, migration set (expect at least a sync-state/cursor table and possibly a change-log table), and whether the service provider registers routes automatically or requires you to opt in. Check php artisan route:list | grep -i sync immediately; if nothing shows up, the package likely expects you to mount its routes yourself or gate them behind a config flag.
Where to look first, in order:
config/horusync.php — the knobs that matter will be around auth middleware, batch size, and conflict strategy.boot() — reveals route registration, middleware stack, and any publishable assets.src/ for the sync controller/endpoint and the model-side contract (trait, interface, or attribute) your Eloquent models must implement.First use case: wire up a single low-risk model (a Note or Task — something with an updated_at and a clear owner) end-to-end before touching anything with money or PII in it. Register the model, hit the sync endpoint from a test HTTP client with a real Sanctum token, and confirm both directions: a local row reaches the client, and a client-originated change lands in the database. That one round trip will expose 80% of the integration friction.
Model registration. Most packages in this space expose either a marker interface or a trait. Find the contract in src/Contracts/ (or equivalent) and apply it to your syncable models. Keep the set small and explicit — syncing every model by default is how you end up shipping your users table's password hashes to a phone. Prefer an allowlist in config over a wildcard.
class Note extends Model implements Syncable // verify exact contract name in src/
{
use SyncsWithDevices; // verify trait name
}
The pull/push split. Sync is almost always two operations even when exposed as one endpoint: pull since cursor and push local changes. Design your client around that distinction from day one — it makes retries, partial failures, and "show the user what's pending" trivial. If the package's endpoint is a single combined call, wrap it in your own application service so the client never depends on the package's wire format directly.
Cursor-based pulls, not timestamp comparisons in your own code. Whatever the package tracks — a monotonic version, a change-log ID, or updated_at — let it own that cursor. If you hand-roll where('updated_at', '>', $clientLastSync) you will lose writes that commit out of order or land in the same microsecond.
Conflict resolution should be a deliberate policy, not a default. For most mobile apps, last-write-wins per-field is acceptable and version-vector merging is not. Write the rule down and encode it: if the package offers a hook (event, resolver class, or overridable method) for conflicts, use it to log every conflict your resolution silently discards. Those logs are the only way you'll ever debug "my edit disappeared."
// Illustrative shape — confirm the actual extension point in src/
Event::listen(SyncConflictDetected::class, function ($event) {
Log::warning('sync.conflict', [
'model' => $event->model,
'device' => $event->deviceId,
'resolution' => 'server_wins',
]);
});
Batch and chunk aggressively. Mobile clients on cellular will send you 500-row payloads and time out. Cap batch size in config, return a partial-success response shape (accepted / rejected / conflicting IDs) so the client can retry only what failed, and never return a bare 500 for a single bad row — that turns one malformed record into an unrecoverable sync loop.
Authentication and tenancy. Wrap the sync routes in auth:sanctum (or your package's configured middleware) and scope every query by the authenticated user/tenant. A sync endpoint that trusts a device_id from the request body without verifying it belongs to the caller is a horizontal-privilege-escalation bug. Issue one token per device so revocation is granular; a shared token means a lost phone requires logging out every user.
Queue the expensive parts. If the package exposes a merge/apply step, push it onto a queue and return 202 Accepted with a job ID. Sync clients poll; they don't need a synchronous 8-second response holding a connection open on a train.
Testing pattern. Write HTTP-level feature tests that fake a device: authenticate, push a change, pull, assert idempotency by replaying the identical payload and asserting no duplicate rows. Add a test for the offline-replay case (push the same change twice with the same client-generated ID) — that's where these packages most often fall over.
AGPL-3.0 is a real decision, not a formality. The AGPL's network clause means running a modified version as a hosted service can obligate you to publish source. For an internal-only backend this is usually fine; for a commercial SaaS, get legal sign-off before the first commit, and consider whether you need a commercial license from AppTank. This is the single highest-impact item in this assessment.
Zero dependents means zero field reports. You are the first person to hit most bugs. Concretely: fork it, keep your fork in a VCS you control, and vendor it via a path or VCS repository entry so an upstream deletion or force-push doesn't destroy your build. Write down the version you forked from.
Clock skew and timezone. Mobile devices lie about time. Store everything in UTC, never trust a client-supplied timestamp for ordering, and prefer server-assigned versions. If the package uses updated_at as its cursor, verify the column is timestamp(6) or better — second-granularity cursors silently drop rows on busy tables.
Deletes are the classic failure. Hard-deleting a row means the client never learns it should delete. Confirm whether the package uses soft deletes, tombstones, or nothing at all. If nothing, add SoftDeletes to syncable models and handle tombstone propagation yourself — otherwise deleted records resurrect on the next pull from a device that still has them cached.
Idempotency on push. Assume every push is retried at least once. Give the client a generated UUID per change so the server can drop duplicates; if the package doesn't support this, add a client_change_id unique index on your side.
Config caching bites. php artisan config:cache will freeze your sync config. If you're toggling middleware or model registration during development and nothing changes, that's why — php artisan config:clear.
Debugging strategy. Log the full payload ID-and-cursor envelope (not the whole body) on every sync request via middleware, with a correlation ID the client echoes back. When a user reports "my notes are gone," the first question is always "what did the last pull return," and without that log you have no answer. Consider a horusync:status artisan command that dumps the current cursor and pending-change count per device.
Extension points to prefer over forking: Laravel-idiomatic packages usually let you override the controller, swap the conflict resolver, or listen to events. Exhaust those before patching vendor/. If you must patch, use composer-patches so the change is recorded and re-applied, and file it upstream — with 0 dependents, you may be the only person who will ever hit that bug.
Have an exit plan. Because this is a pre-traction package, keep the sync layer behind a thin application interface (SyncService with push(), pull(), status()) that your controllers call. If you later swap to a DIY implementation or a more mature alternative, you change one class instead of forty call sites.
How can I help you explore Laravel packages today?