zuko/syncro-sheet
Laravel package for syncing Eloquent models with Google Sheets. Export records, import spreadsheet rows, and keep data in sync through configurable mappings and queued jobs—ideal for lightweight admin exports and editable data workflows.
The 1.0 release is deliberately minimal — "basically sync features" — so treat it as a working core rather than a finished product. There is no ecosystem of docs to lean on yet, which changes the onboarding order: read the source before you read a README.
composer require <vendor>/<package>:^1.0
php artisan vendor:publish --provider="Vendor\Package\PackageServiceProvider"
^1.0 is fine, but avoid * or dev-main until the API stabilises — a 1.x minor release in a young package can still move method signatures.src/*ServiceProvider.php — what's registered, whether a facade/singleton is bound, whether commands exist (php artisan list | grep <package>).config/<package>.php — connection/auth settings. This is your contract with the remote side.CHANGELOG.md / tagged diffs between 0.x and 1.0 if the package existed pre-release.Product rows to the remote, or pull them down. Do it in tinker before you write a job:
$syncer = app(\Vendor\Package\Syncer::class);
$syncer->sync(Product::query()->limit(1)->get());
Confirm the payload shape and the response before scaling up. If sync() accepts a Collection, you now have a chunkById() story; if it accepts a single model, you have a loop and a job.The first useful deliverable is almost never "sync everything" — it's "sync one model, idempotently, with a unique index in place so a second run is a no-op."
Wrap the sync in an idempotent job. Sync features fail halfway. Make re-running safe by keying on a stable external identifier and using upsert() with a unique index rather than updateOrCreate() in a loop.
final class SyncProducts implements ShouldQueue, ShouldBeUnique
{
use InteractsWithQueue, Queueable, SerializesModels;
public int $tries = 3;
public int $backoff = [10, 60, 300];
public function handle(Syncer $syncer): void
{
Product::query()->chunkById(200, function ($products) use ($syncer) {
$syncer->sync($products);
});
}
public function uniqueId(): string { return 'sync-products'; }
}
Always pass IDs, not models, across the queue boundary. SerializesModels re-queries on unserialize; a model deleted between dispatch and handle becomes a ModelNotFoundException in the middle of a batch. chunkById inside the job re-reads fresh state and avoids stale hydration.
One direction per job. Bidirectional sync in a single job produces unresolvable merge logic and infinite echo loops once you add model events. Two jobs, SyncDown and SyncUp, each with its own cursor/timestamp watermark.
Use watermarks, not full scans. Track last_synced_at (per-entity, in a small state table or cache) and query where('updated_at', '>', $watermark). Overlap the window by a minute to cover clock skew, and dedupe on the unique key — cheap insurance against drift.
Deletion semantics are a design decision you must make explicitly. Most young sync packages handle create/update and leave deletes to you. Pick one:
remote_deleted_at column and filter reads.Never let the package's absence of a delete story silently become "deletes never propagate" without documenting it.
Compose with Laravel primitives rather than fighting them:
Http::retry(3, 200)->timeout(10) // if you're wrapping the transport
Bus::batch([...])->allowFailures()->dispatch();
and schedule on a dedicated queue so a slow sync can't starve user-facing jobs:
$schedule->job(new SyncProducts)->everyFiveMinutes()->onOneServer();
Test against fakes, not the network. Http::fake() / Queue::fake() plus a fixture of the remote payload shape. Assert on the database after handle(), not on call counts — call counts break the moment you add chunking.
App\Sync\ProductSyncer) so a 1.1 rename is a one-file change.DB::transaction() protects your side only. If the remote write succeeds and your commit fails, you're inconsistent. Mitigate with idempotency keys sent to the remote, and a sync_log table recording intent before the call.env() outside the config file itself, php artisan config:cache will silently null it. Check the published config; if it calls env() in a service provider, that's a bug — read the config key instead.Carbon in app timezone vs remote UTC is the single most common cause of "it keeps re-syncing the same rows." Store and compare in UTC; cast explicitly (->utc()->toIso8601String()).$tries > 1.upsert() without a unique index is a silent duplicator. Add the migration before the first sync run, not after.->get() on a 100k-row table plus a JSON encode will hit memory_limit. chunkById(200) and stream where possible.Log::info('sync', ['entity' => 'product', 'id' => $id, 'http_status' => ...]). If the package dispatches events (check for SyncStarted/SyncFailed), attach a listener rather than editing vendor code. php artisan about confirms whether the provider actually discovered; a missing facade binding is usually a cached config from a previous install.Contracts\Syncer) you can rebind in your own provider, Macroable traits on the client, and any sync.* config array of per-entity handlers. Those are the supported seams — prefer them over subclassing internals.How can I help you explore Laravel packages today?