ruwork/reminder
ruwork/reminder is a PHP package for scheduling and managing reminders in your application. Define reminder rules, persist pending notifications, and trigger them when due through a clean, framework-friendly API—useful for automating time-based follow-ups and user notifications.
Before writing any code, treat this as a source-first package. With zero stars and zero dependents there is no community surface, no Stack Overflow corpus, and no blog coverage — the src/ directory and composer.json are the documentation.
composer.json on the repo and check the require block. Ruwork packages are historically Symfony-oriented (bundles, Doctrine integrations, PSR-18 clients), so confirm whether you are pulling in symfony/framework-bundle, Doctrine ORM, Messenger, or Mailer as transitive dependencies. If your project is Laravel, decide up front whether you are willing to pull a Symfony bundle in alongside it (you can — bundles are just DI containers — but auto-discovery and Kernel::registerBundles() do not exist in Laravel, so you would instantiate services manually or via a small service provider).composer require ruwork/reminder. If it resolves cleanly, you immediately learn the supported PHP and framework versions from the lockfile diff.tests/ exists it is the only executable spec you have. Read them before the README; the tests encode the real contract (what a "reminder" is, how it is persisted, how it is emitted).remindAt field; (b) a storage/query layer that finds due reminders; (c) a transport that fires them (Messenger message, mailer, event dispatcher, webhook). Find each one before designing anything.Command/ or Console/), and confirm exactly one side effect occurs. That single end-to-end run exposes the timezone assumption, the polling interval, and the dispatch mechanism in one shot.If the README is empty and the tests are thin, budget half a day to reverse-engineer the contract. With a package this small, that is realistic; it is also the point at which many teams decide to hand-roll the equivalent in ~150 lines instead.
Cron plus a locking guard. The canonical deployment for any reminder package is a scheduler entry running every minute. Pair it with a distributed lock (flock, database advisory lock, Redis SET NX, or Symfony's Lock component) so overlapping cron runs do not double-fire reminders. This is the single most important pattern for this category of package, because reminder dispatch is rarely idempotent on its own.
Status state machine, not deletion. Model reminders with explicit states — pending → claimed → sent (plus failed, cancelled) — rather than deleting on dispatch. Claim-then-send (mark claimed with a worker ID and a claimed_at before actually sending) gives you crash safety: a reminder stuck in claimed past a timeout can be reclaimed and retried. If the package ships its own entity, check whether it already models status; if it only has remind_at and sent_at, you will need to extend or wrap it.
cron: * * * * * -> dispatcher --limit=100
├── SELECT ... WHERE remind_at <= now() AND status = 'pending' FOR UPDATE SKIP LOCKED
├── UPDATE status = 'claimed', claimed_at = now()
├── emit side effects (message / mail / event)
└── UPDATE status = 'sent' (or 'failed' + scheduled retry)
Batch with a hard limit. Always cap how many reminders one run processes. A backlog (cron was down over the weekend) must not produce a thundering herd of emails. If the package exposes a --limit/--batch option, use it; if not, wrap the call or subclass the command.
Idempotency keys at the consumer. Even with locking, at-least-once delivery is the realistic guarantee. Give every emitted reminder a stable identifier (reminder ID + recipient ID) and make the downstream consumer deduplicate — a unique index in a notifications table, or an idempotency key passed to your mail/webhook provider. Assume the package will occasionally fire twice and design so that firing twice is harmless.
Separate "find due" from "send". The healthy integration splits the dispatcher into two phases: a fast query that enqueues (Messenger message, Laravel job, queue row) and a slow worker that sends. This keeps the cron tick short, isolates retries from discovery, and lets you scale sending independently. If the package is monolithic (query + send in one command), wrap it: run the discovery query yourself and hand results to your queue.
Per-tenant / per-channel partitioning. In multi-tenant apps, run the dispatcher once per tenant with a tenant ID argument, or include tenant ID in the claim query. Shared dispatch across tenants is fine only if your downstream consumers are already tenant-aware; otherwise you will leak reminders across boundaries during a retry.
Timezone discipline. Store remind_at in UTC, always. Resolve user-facing times at the boundary: what the user meant ("9am local") should be converted to a UTC instant at creation time, not at dispatch time — otherwise a DST transition silently shifts everyone's reminders by an hour.
Testing. Use a fake clock. Symfony's ClockMock (or Laravel's Carbon::setTestNow) lets you assert that "2 seconds ago" is due and "1 second from now" is not, without sleeping. Wrap the dispatcher in a test seam so tests can invoke one tick synchronously.
composer update cannot break you. Lock the exact version ("ruwork/reminder": "x.y.z", not ^x.y).composer require will not auto-register anything in Laravel, and there is no ServiceProvider. You will construct the dispatcher manually with explicit dependencies — which is fine, but do not expect Laravel's facade/container magic to reach into it.last_run_at row that a monitor checks against now() - interval. Otherwise a broken cron means reminders quietly stop and you find out from a customer.WHERE remind_at <= now() index. Without a composite index on (status, remind_at) the dispatcher degrades linearly as the table grows; on a table that only ever accumulates, this bites within months.--dry-run, add one in a subclass or a wrapper that logs what would be claimed. Running the live dispatcher for the first time against production data is how teams blast old reminders to thousands of users. Seed with remind_at set in the future, not in the past.attempts counter and a dead-letter state, and treat "failed N times" as a business event someone should see.src/ you find the package is ~200 lines of query-plus-send with no locking, no status model, and no tests, consider implementing the pattern yourself. At this scale, vendor lock-in buys you little, and in-house code you fully understand will be cheaper to operate than an unmaintained dependency. Adopt the package only if it genuinely saves work — for example if it already implements the claim/retry state machine and Doctrine query correctly.How can I help you explore Laravel packages today?