Attribute reference¶
A class declares a list of rules, one per family of public URLs it has. PHP writes them as attributes; every
other language in the family writes decorators or config objects. All of them compile down to the same
IndexNowKit\Attribute\UrlRule, and everything downstream — event classification, guards, locales, via
delegation, deduplication, explain output — consumes only that.
The three attributes¶
| Attribute | Target | Purpose |
|---|---|---|
#[IndexNow] |
class, repeatable | one URL rule |
#[IndexNowDefaults] |
class | policy shared by every rule of the class and its subclasses |
#[IndexNowUrl] |
public method | the method's return value is a URL family (the get_absolute_url() convention) |
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, IndexNowUrl};
#[IndexNowDefaults(when: 'isPublished', fields: ['slug', 'title', 'body', 'published'])]
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'])]
#[IndexNow(route: 'post_amp', params: ['slug' => 'slug'], when: 'hasAmp', whenFields: ['ampEnabled'])]
class Post {}
Sources¶
Exactly one source per #[IndexNow]. Zero sources, or two, throws ConfigurationException at compile time with a
message naming the offenders.
| Source | Value | Produces |
|---|---|---|
route |
framework route name | one URL per locale, generated by the adapter's RouteUrlResolverInterface |
resolver |
UrlResolverInterface class name or service id |
whatever the resolver returns |
via |
accessor to a related object or collection | the related objects' own URLs, resolved as updates |
url |
accessor returning string, iterable<string> or null |
those URLs |
urls |
list of literal URLs | those URLs, absolute or base_url-relative |
url and urls are easy to swap, so both are checked: url: '/about' and urls: ['aboutUrl'] are rejected with a
message telling you which one you meant.
resolver needs a ResolverLocatorInterface. In plain PHP that is ArrayResolverLocator, which also instantiates
a class name on demand as long as its constructor takes no required arguments. Framework adapters look the id up in
the container.
Parameters¶
params maps a route parameter name to a source. A plain string is the accessor DSL; anything else is one of four
typed Param\ParamValue objects.
The accessor DSL¶
Resolved in this order, on the object itself:
'self'— the object (route model binding:params: ['post' => 'self']);- a dotted path — each segment resolved recursively (
'category.slug'); a non-object segment throws; - a method with that exact name;
get,isorhasplus the capitalised name ('published'findsgetPublished(), thenisPublished(), thenhasPublished());- a property, including a private one.
Nothing matched throws ConfigurationException naming the accessor and the class.
Typed sources¶
| Class | Example | Meaning |
|---|---|---|
Param\Accessor |
new Accessor('category.slug') |
the explicit form of a plain accessor string |
Param\Value |
new Value('html') |
a constant |
Param\Formatted |
new Formatted('publishedAt', 'Y') |
DateTimeInterface::format() of the accessor's value |
Param\Call |
new Call('slugFor', Placeholder::Locale) |
a method call; extra arguments are passed as given |
Param\Placeholder::Locale and Param\Placeholder::Host are substituted per generated URL, so a Call can return
a per-locale slug or a per-tenant path. Extraction runs once per URL, not once per rule.
Coercion¶
Route parameters must be usable in a URL. The extractor accepts null and scalars as they are, unwraps a
BackedEnum to its value, casts a Stringable value object to string, and passes plain objects through for route
model binding. A bare DateTimeInterface is rejected with a message pointing at new Formatted(...), because
formatting a date implicitly is how a URL silently changes shape. Anything else throws.
Rule options¶
| Option | Type | Default | Meaning |
|---|---|---|---|
when |
accessor name, a Condition (new Equals(path, value) or your own), or a closure fn(object): bool (runtime rules only) |
inherit | the page exists only while the condition holds |
whenFields |
list of field names | [] |
fields backing this rule's own when when its name does not match the field (a class-level when has its own whenFields in #[IndexNowDefaults]) |
fields |
list of field names, or null |
inherit, then [] |
for updates only: submit when one of these changed; [] = any field |
events |
subset of created, updated, deleted (strings or Event cases), or null |
inherit, then all three | which lifecycle events the rule listens to |
locales |
'current', 'all' or a list, or null |
inherit, then 'current' |
locale expansion for localized routes |
host |
string or ParamValue |
null |
generate this rule's URLs on that host (multi-domain) |
name |
string | derived | stable rule id for logs, explain output and subclass overrides |
when is a conjunction: the class-level when and the rule's own when must both hold. fields,
events and locales are defaults a rule overrides; null means inherit, [] means "no filter".
An accessor string is checked for truthiness, which is right for booleans and wrong for a status string ('draft' is
truthy). For string or enum states use Equals, which also gives exact old-state detection from the ORM change set:
use IndexNowKit\Attribute\Param\Equals;
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: new Equals('status', 'published'))]
#[IndexNow(route: 'job_show', params: ['id' => 'id'], when: new Equals('state', JobState::Open))] // BackedEnum or its value
Rules registered at runtime (RuleRegistry) may pass a closure: when: fn (WP_Post $p): bool => $p->post_status === 'publish'.
A closure's old value cannot be reconstructed, so list the fields it reads in whenFields; a change of one of them is
treated as a visibility flip (see the semantics table).
Your own conditions¶
Equals is an Attribute\Param\Condition — evaluate(object $subject): bool — and any class implementing it goes
in when the same way (an attribute argument must be a constant expression, so a condition class with a constructor
of scalars, not a closure):
use IndexNowKit\Attribute\Param\Condition;
use IndexNowKit\Attribute\Param\FieldCondition;
final readonly class Between implements Condition
{
public function __construct(private string $path, private int $min, private int $max) {}
public function evaluate(object $subject): bool
{
$value = ParamExtractor::read($subject, $this->path);
return is_int($value) && $value >= $this->min && $value <= $this->max;
}
}
final readonly class OneOf implements FieldCondition // reads one field: the classifier sees the old state
{
/** @param list<string> $values */
public function __construct(private string $path, private array $values) {}
public function evaluate(object $subject): bool { return $this->heldFor(ParamExtractor::read($subject, $this->path)); }
public function field(): string { return $this->path; }
public function heldFor(mixed $oldValue): bool { return in_array($oldValue, $this->values, true); }
}
#[IndexNow(route: 'offer_show', params: ['id' => 'id'], when: new OneOf('state', ['open', 'reserved']))]
A plain Condition has no old value: ChangeClassifier evaluates it on the current object, so open → closed is
classified as a plain update, not as the deletion it is — unless whenFields names the field the condition reads
(then a change of that field counts as a flip). Implement FieldCondition (field(), heldFor($oldValue)) when
the condition reads one field, and the change set gives the exact old state, as it does for Equals. Condition and
FieldCondition are in the Implement tier of bc.md, with the pre-1.0 caveat that they are new in 0.8.
Equals is a condition, not a value source: params: ['status' => new Equals(...)] is a type error, and
ParamExtractor names the fix. explain prints every condition with the value it read (when: status ("draft") ->
true — a non-empty string is truthy; use new Equals('status', "draft")), and explain --json gives the same walk
as a document.
An unknown event name throws ConfigurationException naming the attribute and the value.
Rule names¶
Derived from the source when not given: the route name; resolver:<ShortClassName>; via:<accessor>;
url:<accessor> (and url:<method> for #[IndexNowUrl]); urls:<first two literals>. Two rules of the same class
that would derive the same name get #2, #3 appended in declaration order. Give an explicit name whenever you
intend a subclass to override a specific rule, or whenever the derived name would be unstable.
Class defaults and inheritance¶
The compiler walks the class hierarchy root first, then the leaf.
#[IndexNowDefaults]merges field by field, the nearest declaration wins. A declaration that sets its ownwhenalso replaces the inheritedwhenFields; one that does not adds to them.- Rules accumulate. A rule whose name repeats an ancestor's replaces it; a new name adds a page. That is how a subclass changes one page without restating the others.
#[IndexNowUrl]is read on public methods declared by each class in the chain, so an override in a subclass wins. The method must not require arguments.- Interfaces and traits are not scanned: PHP does not inherit class attributes through them, and Doctrine mapping behaves the same way.
#[IndexNowDefaults(when: 'isPublished')]
#[IndexNow(route: 'content_show', params: ['slug' => 'slug'])]
abstract class Content {}
#[IndexNow(route: 'content_show', params: ['slug' => 'slug', 'section' => new Value('news')])] // replaces
#[IndexNow(route: 'news_amp', params: ['slug' => 'slug'])] // adds
class News extends Content {}
A hierarchy where only some subclasses have public pages should carry no rules on the base class: what is not declared is not inherited.
Semantics: event, before, after¶
Visibility is evaluated per rule. W is the conjunction of the class when and the rule's when; W_before is
reconstructed from the ORM change set.
| ORM event | W_before |
W_after |
fields match |
Rule event | State the URL is built from |
|---|---|---|---|---|---|
| insert | — | true | — | Created if subscribed |
new state, after the write (ids assigned) |
| insert | — | false | — | none | — |
| update | true | true | yes | Updated if subscribed |
new state, after the write |
| update | true | true | no | none | — |
| update | true | false | ignored | Deleted if subscribed |
current state, before the write |
| update | false | true | ignored | Created if subscribed |
new state, after the write |
| update | false | false | — | none | — |
| delete | — | true | — | Deleted if subscribed |
pre-delete state |
| delete | — | false | — | none | — |
via target, any event |
— | true | per target rule | target resolved as Updated |
the target's own rules and guards |
via target, any event |
— | false | — | none | — |
Two consequences worth stating out loud. fields never suppresses a visibility transition, only a plain update: a
page that just went dark is announced whichever field did it. And deleting an object whose rule does not apply
submits nothing — that page was never public, so purging drafts stays quiet.
Reconstructing W_before¶
ChangeClassifier::classify(UrlRule $rule, object $subject, array $changedFields, array $changeSet = []) returns the
Event a rule cares about, or null. Old-state visibility is best effort, in three tiers:
- A
whenaccessor whose backing field is present in the change set is evaluated exactly from the old value. The backing field is found by name, then by convention:isPublished → published → is_published,hasAmp → amp → has_amp,getStatus → status.UrlRule::fieldCandidates()exposes that list. - An accessor with no change-set entry, but a field it depends on (its candidates, or a declared
whenFieldsentry) among the changed fields, is assumed to have flipped. A false positive costs one request; a false negative leaves a dead page in the index. - Otherwise the accessor keeps its current value.
Name the fields with whenFields when the accessor is a method whose name has nothing to do with the column, for
example when: 'isVisibleToPublic', whenFields: ['status', 'visibleFrom'].
via¶
via resubmits a related object's pages: a changed comment refreshes its post, a changed product refreshes its
category. Targets are always resolved as Updated, because their page exists regardless of what happened to the
source. Depth is capped at 3 and fan-out at 100 related objects per rule (constructor arguments maxViaDepth and
maxViaFanout of AttributeUrlResolver); exceeding the depth throws, exceeding the fan-out logs a warning and
stops. A target rule that delegates back through the same accessor name is skipped, so A -> B -> A terminates.
Resulting URLs keep the whole chain in their rule name: via:category -> category_show.
Field names¶
fields and whenFields are model field names as the developer writes them, never database columns.
Doctrine's getEntityChangeSet() gives exactly those. A declared field matches a changed one when they are equal or
one is a dotted prefix of the other, so fields: ['address'] catches an embeddable change reported as
address.city.
Types adapters consume¶
final readonly class UrlRule
{
public string $name;
public RuleSource $source; // Route|Resolver|Via|Url|Urls
public ?string $route; public array $params;
public ?string $resolver; public ?string $via; public ?string $url; public array $urls;
public array $when; public array $whenFields; public array $fields;
public array $events; public array|string $locales;
public string|ParamValue|null $host;
public function listensTo(Event $event): bool;
public function caresAbout(array $changedFields): bool;
public function appliesTo(object $subject): bool; // every `when` accessor is truthy
public function whenDependsOn(string $field): bool;
public static function fieldCandidates(string $accessor): array;
}
RuleSet is every rule of one class in declaration order (parents first). It is Countable and iterable, empty for
classes without rules so callers never branch on null, and offers isEmpty(), get(string $name) and
listensTo(Event $event) as a cheap pre-filter for ORM hooks.
AttributeReaderInterface::rules(string|object $classOrObject): RuleSet is the lookup. The default
AttributeReader compiles attributes through RuleCompiler and caches per class for the process lifetime. It
throws ConfigurationException on a malformed declaration — ORM hooks must read through ObjectChangeHandler or
GuardedUrlResolver, which log instead.
ResolvedUrl carries provenance for explain output, logs and profiler panels: url, rule, class, event,
locale, plus source() (App\Entity\Post#post_amp) and ResolvedUrl::urls() to flatten a list to deduplicated
strings.
Rules registered at runtime¶
Models that cannot carry attributes — CMS post types, classes you do not own, a closure API — use RuleRegistry,
which implements AttributeReaderInterface on top of an inner reader (attributes by default).
use IndexNowKit\Attribute\{IndexNow, IndexNowDefaults, RuleRegistry};
$registry = new RuleRegistry();
$registry->register(Post::class, [
new IndexNow(route: 'posts.show', params: ['post' => 'self']),
new IndexNow(urls: ['/']),
], new IndexNowDefaults(when: 'isPublished'));
$registry->register(WP_Post::class, [new IndexNow(resolver: 'wp_permalink')], new IndexNowDefaults(
when: fn (WP_Post $post): bool => $post->post_status === 'publish', // or: new Equals('post_status', 'publish')
whenFields: ['post_status'],
));
$registry->registerFor(CmsPage::class, fn (CmsPage $page): ?RuleSet => $page->isSystem() ? null : $rulesFor($page));
$indexNow = IndexNowKit::create($config, attributes: $registry);
register() compiles attribute instances built in code, with no reflection. registerFor() decides per object and
may return null to fall through to the inner reader. Registered rules replace whatever the inner reader would
return for that class, and subclasses inherit them.
Resolving without an ORM¶
AttributeUrlResolver resolves every rule of a class through its source; GuardedUrlResolver wraps it so nothing
throws. ObjectChangeHandler is the piece ORM hooks build on: it classifies a created, updated or deleted object
per rule and resolves the URLs, logging every silent outcome. See adapters.md.
Anti-patterns¶
Five declarations that compile, run, and submit the wrong thing.
1. A literal URL in url:. url: names an accessor; urls: lists literals.
#[IndexNow(url: '/')] // wrong: reads a property or method called "/"
#[IndexNow(urls: ['/'])] // right
#[IndexNow(url: 'canonicalUrl')] // right: $post->canonicalUrl() or ->canonicalUrl
2. A status string in when:. A string is an accessor read as truthy, so 'published' means "the attribute
published is truthy" — a status column holding 'draft' is truthy too. explain shows the value it read and
says so.
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: 'status')] // wrong: 'draft' is truthy
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: new Equals('status', 'published'))] // right
#[IndexNow(route: 'post_show', params: ['slug' => 'slug', 'v' => new Equals('status', 'published')])] // wrong: a condition is not a param value (type error)
2b. A custom Condition on a field that flips. when: new Published() that reads status internally cannot
tell the classifier what the old state was: published → draft is an update, the dead page stays indexed.
Implement FieldCondition, or name the field in whenFields.
3. A rule on a page the engine must not index. A preview, an admin page, a page with noindex or a
robots.txt disallow: the engine fetches it, finds it unindexable, and counts a mistake against the key.
#[IndexNow(route: 'post_preview', params: ['slug' => 'slug'])] // wrong: preview pages carry noindex
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'], when: 'isPublished')] // right: the public page, guarded
4. Non-canonical URLs. Filter and sort variants, tracking parameters, the apex next to www, http next to
https: submit the canonical page once. Generate URLs through the router with base_url on the canonical origin;
do not build them by string concatenation from the request.
#[IndexNow(urls: ['/products?sort=price&utm_source=indexnow'])] // wrong: a variant, and a tracking parameter
#[IndexNow(route: 'products_index')] // right: the canonical listing
5. No when on a model that has drafts. Without a guard every save submits, drafts included, and a page taken
down is announced as an update, not a deletion.
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // wrong when Post has a draft state
#[IndexNowDefaults(when: 'isPublished')] // right: drafts are skipped,
#[IndexNow(route: 'post_show', params: ['slug' => 'slug'])] // published → draft is a deletion
What the library checks for you: the URL is absolute http(s), has no credentials, fragment or control characters,
and belongs to a host you hold a key for (strict_hosts). What it cannot check: noindex, robots.txt, a canonical
pointing elsewhere, the status code the page answers — that is the rule author's job today, and the job of the
verify add-on (check --sample) in a later release.