While working on scheduled IndexNow in ICanUp, I ran into a problem ordinary synchronous code does not have: a long time can pass between scheduling a job and actually running it.
During that time, a Post can be edited, its published_at can move, its slug can change, or the content can stop being public entirely.
A delayed job should not assume that conditions that were correct at dispatch time are still correct at execution time.

The Real Scheduled Publication Flow in ICanUp
This case came from my IndexNow implementation for automatically submitting URLs to Bing.
It also continues the same transaction boundary I described in my article about Laravel DB::afterCommit() and Queue Jobs: database state becomes committed first, and only then does the system schedule the external reaction.
If a Post already has the published status but its published_at is still in the future, ICanUp schedules a delayed IndexNow job for that exact time.
The job itself does not publish the Post. It reacts to the moment when the content is expected to become publicly visible.
public function schedulePost(Post $post): void{ if ( ! $post->isPublished() || $post->published_at === null || ! $post->published_at->isFuture() ) { return; } SubmitScheduledPostIndexNowJob::dispatch( $post->getKey(), $post->published_at->getTimestamp(), )->delay($post->published_at);}The scheduler passes only the Post ID and expected publication timestamp. The Post object and URL list are not frozen inside the queued job.
What the Job Remembers at Scheduling Time
The constructor receives two values: postId and expectedPublishedAtTimestamp.
The timestamp acts as a simple version marker for the scheduled publication.
The job does not remember the entire old state. It remembers which exact publication time it was created for.
public function __construct( public int $postId, public int $expectedPublishedAtTimestamp,) {}The Unique Job Depends on Both the Post and Publication Time
public function uniqueId(): string{ return sprintf( 'post:%d:%d', $this->postId, $this->expectedPublishedAtTimestamp, );}For Post 15 scheduled for timestamp 1000, the unique key is post:15:1000.
If publication moves to timestamp 2000, the new job receives post:15:2000.
Rescheduling creates a new version of the future operation instead of trying to make the old job correct again.
| State | Unique ID | Result |
|---|---|---|
| Post 15, publication at 18:00 | post:15:1000 | First scheduled job |
| Same scheduling repeated | post:15:1000 | Duplicate blocked by uniqueness |
| Publication rescheduled | post:15:2000 | New scheduled job |
| Old job wakes up | post:15:1000 | Must verify whether its timestamp is still current |
The Old Job Is Not Deleted - It Invalidates Itself
My first instinct could have been to physically remove the previous delayed job from the queue after every reschedule.
That would create a much more complicated coordination problem.
In my implementation, the old job can remain in the queue. When it wakes up, it checks whether it still matches the current publication state.
if ( $post->published_at === null || $post->published_at->getTimestamp() !== $this->expectedPublishedAtTimestamp) { return;}The Job Reloads the Entity from the Database
$post = Post::query()->find($this->postId); if ($post === null) { return;}I do not put a serialized snapshot of all IndexNow data into the queue.
The job receives an ID and loads the Post again when it runs.
The decision is therefore based on current database state at execution time, not a copy from scheduling time.
A Matching Timestamp Still Does Not Guarantee Public Content
if (! $post->isPubliclyVisible()) { return;}Even when the timestamp still matches, the job does not submit URLs if the Post is no longer publicly visible.
Scheduled publication can become stale for reasons other than time.
The content may be deactivated, archived, or changed in another way before execution.
Every external operation should recheck the business condition it actually depends on.
The URLs Are Generated Again Too
$urls = $indexNowUrlService->postTargets($post); if ($urls === []) { return;} $client->submit($urls);The scheduler does not put a list of URLs into the delayed job either.
If the slug changes after scheduling, the job submits the new current URL.
This is especially useful for multilingual content because ICanUp can generate the current localized targets instead of relying on an old snapshot.
SubmitScheduledPostIndexNowJob::dispatch( $post->id, $urls,)->delay($post->published_at);SubmitScheduledPostIndexNowJob::dispatch( $post->id, $post->published_at->getTimestamp(),)->delay($post->published_at);I prefer carrying a minimal identity and version contract and generating current URLs later instead of freezing an external payload hours in advance.
The Complete Guard Sequence Before the Side Effect
$post = Post::query()->find($this->postId); if ($post === null) { return;} if ( $post->published_at === null || $post->published_at->getTimestamp() !== $this->expectedPublishedAtTimestamp) { return;} if (! $post->isPubliclyVisible()) { return;} $urls = $indexNowUrlService->postTargets($post); if ($urls === []) { return;} $client->submit($urls);- The entity still exists.
- The publication time is still the same.
- The content is publicly visible now.
- Current public URLs exist.
- Only then is the external IndexNow submission allowed to happen.
Stale State and Temporary Failure Are Different Problems
#[Tries(3)]#[Backoff([60, 300, 900])]final class SubmitScheduledPostIndexNowJob implements ShouldBeUnique, ShouldQueue{ // ...}If a job is stale, retrying it will not help. Its business condition is no longer valid, so the correct behavior is to exit.
If current state is valid but the external service fails temporarily, a retry makes sense.
Not every failure to act is an error that should be retried.
| Reason | Action |
|---|---|
| Publication time changed | Return |
| Entity deleted | Return |
| Content no longer public | Return |
| No current URLs | Return |
| Temporary external submission failure | Retry |
| Duplicate identical scheduling | ShouldBeUnique |
What I Verify in Tests
- A future published Post receives a delayed job for the exact published_at.
- Draft or already public content does not create a scheduled job.
- The same Post and timestamp produce the same unique ID.
- A different publication time produces a different unique ID.
- An old job submits nothing after rescheduling.
- The job uses the current slug and current URLs.
- Content that is no longer public is not submitted to IndexNow.
This Pattern Is Bigger Than Scheduled Publication
IndexNow was the concrete use case, but the pattern is much broader.
The same problem appears with delayed emails, reminders, notifications, payment checks, scheduled exports, or any operation that runs minutes or hours after scheduling.
The longer the gap between decision and execution, the more important it becomes to recheck the business condition.
A delayed job is a future intention, not permanent truth about the system.
What I Kept for Next Time
- Do not put more historical state into a delayed job than necessary.
- Use an ID and version marker when the current entity can be reloaded later.
- Treat rescheduling as a new version of the future operation.
- Do not require physical cancellation of stale jobs when they can safely invalidate themselves.
- Recheck the business condition immediately before the side effect.
- Generate external payloads as close to execution time as possible.
- Separate a stale no-op from a temporary failure that actually deserves a retry.
Conclusion
The most important part of the scheduled IndexNow flow in ICanUp was not the delay() call itself.
The real problem starts after scheduling: the system keeps changing, and state may be different by the time the job runs.
That is why my delayed job keeps a minimal contract, reloads the entity, compares the expected publication timestamp, checks public visibility, and only then generates current URLs.
A job does not run just because it was once put on the queue. It runs only when the reason it was queued is still valid.



