Profile    Mohammed Shiroz Status   Loading  
Logo
Share This
Back to blog
Filter by:
Tags
//Article title

Building Contract Renewal Reminders That Don't Annoy Anyone

21 Sep 2026Category : Blog

About Post

On paper, a contract renewal reminder is the easiest feature in the backlog. Find contracts ending soon, send an email. A scheduled job and a notification. Half a day, tops.

Then reality joins the meeting. The same tenant gets the reminder twice because the job ran twice. A contract added to the system late gets three reminders on the same morning. Someone who renewed last week is still being asked to renew. And the property manager receives forty separate emails before lunch.

Kate PMS handles the full life of tenancy contracts, from e-signing to rent to renewals, across more than 2,000 contracts. At that scale, "send a reminder" is really a small system. I won't go into how our implementation works internally, but here are the design principles I think every reminder system needs, with a simplified Laravel sketch of the patterns.

Principle 1: a reminder is a schedule, not a moment

The naive query looks for contracts ending exactly 30 days from today. It works perfectly until the scheduler skips a day (a deploy, a server restart, a failed job). Every contract that hit its 30-day mark on that day silently gets no reminder at all.

A sturdier approach treats each reminder as a stage that becomes due and stays due until it's handled. "The 30-day reminder is due if the contract ends within 30 days, hasn't ended yet, and we haven't recorded sending it." If a run is missed, the next run catches up.

$today = CarbonImmutable::today('Asia/Qatar');

foreach (self::STAGES as $stage => $daysBefore) { // e.g. ['first' => 90, ...]
    Contract::query()
        ->where('status', 'active')
        ->whereNull('renewal_decided_at')
        ->whereDate('ends_on', '>=', $today)
        ->whereDate('ends_on', '<=', $today->addDays($daysBefore))
        ->whereDoesntHave('renewalReminders', fn ($q) => $q->where('stage', $stage))
        ->chunkById(200, fn ($contracts) => $contracts->each(
            fn (Contract $contract) => SendRenewalReminder::dispatch($contract, $stage)
        ));
}

Notice the immutable date and the explicit time zone. "Thirty days before the end" should mean the business's calendar, not the server's UTC clock.

Principle 2: lead times should match the decision

How early should you remind? Work backwards from the decision people need to make. Renewing a lease isn't a one-click choice: the tenant may need to discuss terms, the owner may want to adjust rent, and there may be a notice period in the contract.

So the stages should come from the business, not from what feels tidy. A sensible pattern is an early heads-up, a reminder when a decision is actually needed, and a final one close to the end date. Keep the numbers in config, not scattered through code, because they will change.

And handle the catch-up edge: if a contract enters the system with 40 days left, it should get the most relevant reminder, not every earlier stage at once. Record the skipped stages as skipped so they don't fire later either.

Principle 3: know when to stop

Nothing annoys people faster than a reminder about something they've already done. Every reminder query needs clear stop conditions:

  • The contract has been renewed, or a renewal is in progress.
  • Notice of non-renewal has been given.
  • The contract was terminated early or replaced.
  • A staff member has marked it as being handled personally.

A single field like renewal_decided_at, set by any of those actions, keeps the query simple. And check it again right before sending, inside the job, because the situation may have changed between the query and the send.

Principle 4: every send is idempotent

Jobs get retried. Schedulers occasionally run twice. Two servers may both think they're in charge. The reminder must still go out once.

The reliable tool is the same one that makes payment retries safe: a database row with a unique constraint, claimed before sending.

public function handle(): void
{
    if ($this->contract->fresh()->renewal_decided_at) {
        return; // decided since this job was queued
    }

    // unique index on (contract_id, stage)
    $claimed = DB::table('renewal_reminders')->insertOrIgnore([
        'contract_id' => $this->contract->id,
        'stage' => $this->stage,
        'created_at' => now(),
    ]);

    if ($claimed === 0) {
        return; // another run already sent it
    }

    $this->contract->tenant->notify(new RenewalReminder($this->contract, $this->stage));
}

There's a trade-off hiding here. Claiming first means that if the email provider fails right after the claim, the reminder is recorded but never delivered (at most once). Sending first risks duplicates (at least once). For reminders, I prefer claim-first plus a status column: mark the row as sent or failed, and let failed rows be retried. A missed reminder can be caught; a duplicate can't be unsent.

On the scheduling side, Laravel helps too: withoutOverlapping() stops a slow run from overlapping the next, and onOneServer() stops several servers running the same task (it needs a shared cache that supports locks, like Redis or the database driver).

Principle 5: respect how people want to hear from you

Tenants and staff are different audiences with different needs.

  • Tenants want a clear message on the channel they actually read: a push notification in the tenant app, an email, maybe an SMS for the final reminder. Let them choose where reasonable, but treat contract notices as operational messages, not marketing. They shouldn't silently vanish because someone unsubscribed from newsletters.
  • Staff don't want forty emails. They want a digest: one message a day listing the contracts that need attention, sorted by urgency, with links.
  • Timing matters. Send at a sensible local hour, not at whatever minute the job happens to run.

Laravel's notifications make channel choice natural: the via() method can read the recipient's preferences and return the right channels for each person.

Principle 6: escalate, don't repeat

If a tenant hasn't responded after the second reminder, sending the same message a third time to the same person rarely helps. It's better to escalate: put the contract on the responsible staff member's list so a human follows up. Reminders start the conversation; people finish it.

The checklist: stages that stay due until handled, lead times from the business, clear stop conditions checked twice, a unique claim per stage, digests for staff, preferred channels for tenants, and escalation to a human instead of endless repeats.

The real measure

A good reminder system is one nobody talks about. Tenants get one clear message at the right time, staff see a tidy list instead of a flood, and no renewal date sneaks up on anyone. It's not glamorous work, but it's the kind of detail that makes software feel trustworthy.

How do you handle reminders in your apps: fixed dates, due stages, or something smarter? And how do you stop them from nagging?

Comments (0)
Leave your review

Thanks for your valuable comments. Your comments has been updated and appreciate your getting in touch...

01. About Shiroz

Mohammed Shiroz

Hi, I'm Mohammed Shiroz, a software engineer and AI enthusiast from Sri Lanka who turns ideas into intelligent, real-world solutions. With over 9 years of hands-on experience, I currently lead real estate ERP development at Kate Group, a...

03.My Projects

04. Categories

Ready To order Your Project ?

Get in Touch
Close