A Presave Hook's isNew() Check Is the Exact Reason a Scheduled Publish Isn't Blocked

This site's automated publishing pipeline runs under a scoped account, api_publisher, that has no permission to touch a node's status field. That's deliberate: a hook_node_presave() in this site's custom module forces any new node owned by that role unpublished, no matter what the content type's default says or what the API client asked for:

  • function linkhub_ui_node_presave(\Drupal\node\NodeInterface $node) {
  • if ($node->isNew() && $node->getOwner() && $node->getOwner()->hasRole('api_publisher')) {
  • $node->setUnpublished();
  • }
  • }

That guard is the whole safety mechanism behind this pipeline never going live unreviewed. So when I added the Scheduler module a day later — so articles could be drafted now and go live at a specific time later, without a human clicking publish at that exact moment — the obvious question was whether Scheduler's own cron-driven publish would collide with it. If a scheduled node getting flipped live also somehow ran back through this hook and got unpublished again, the whole feature would be dead on arrival.

It doesn't collide, and the reason is sitting right there in the condition: $node->isNew().

What "new" actually means to Drupal

isNew() isn't asking "was this content recently created." It's asking whether the entity has ever been saved before — true during the original insert, false on every save after that. A scheduled article is created once, unpublished, by the API client; Scheduler's cron then loads that same node later, calls setPublished(), and saves it again. That second save is an update to an existing entity, not an insert. isNew() returns false, the && short-circuits, and the hook does nothing at all on that run.

I didn't want to trust that reasoning without seeing it happen, so I tested it directly instead of just reading the code. I created a real node through the publishing pipeline with a publish_on timestamp a few minutes in the past, confirmed it landed unpublished as expected, then ran Scheduler's own cron command by hand:

  • drush scheduler:cron
  • [notice] Article: scheduled publishing of TEST scheduler presave check.
  • [notice] Lightweight cron run completed.

A follow-up query against node_field_data showed status flipped from 0 to 1. No second unpublish, no fight between the two systems. Scheduler's own code makes the same distinction the hook relies on: deep in SchedulerManager::publish(), the actual call that flips the flag is $entity->setPublished()->save() on an entity that was loaded from storage, not constructed fresh — there's no path through Scheduler's cron that ever creates a new node object for this operation.

Why this isn't a coincidence worth worrying about

It would be fair to read "the guard only checks isNew()" as a narrow escape rather than a designed one — as if the hook happens to leave a gap that Scheduler happens to fit through. I don't think that's the right read, because the alternative would actively break the feature it's supposed to protect. If the presave hook unpublished on every save regardless of newness, Scheduler could never publish anything: the instant its cron job flipped the status and saved, the hook would see an api_publisher-owned node and immediately flip it back. The guard has to be scoped to insert-time specifically, because insert-time is the only moment an unreviewed, fully API-driven node actually exists. Every save after that is either a human editing something in the admin UI, or Scheduler executing a change a human already approved by setting the publish time in the first place.

The general lesson is less about Drupal specifically and more about what "the same code path" actually means in a system with hooks. Two features — a safety guard and a scheduling module — never coordinated with each other, were written independently, and still compose correctly, because both of them respect the same distinction between "this row doesn't exist yet" and "this row is being changed." Get that distinction wrong in either direction — guard against every save instead of just inserts, or assume "recently touched" instead of checking the entity's actual lifecycle state — and this stops working, quietly, the first time someone tests it. It's worth checking which one your own hooks actually key off of before you assume they'll keep composing the way you expect.

Add new comment

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.