Skip to the content
Documentation
Help Start
Reading in:
  • German: 9,059 of 9,059 pages
About the help in other languages
All help pages

Request rules: securing content/view/full and every other module view

Exponential 6.0.15. Settings in settings/requestrules.ini. Code in kernel/private/classes/requestrules/. Command bin/php/ezrequestrules.php. Tests in tests/tests/kernel/classes/requestrules/.

Download as PDF

Address: https://doc.exponential.earth/exphelp/viewer/bc/6.0/view_full_security.md

On this page

Request rules: securing content/view/full and every other module view

Exponential 6.0.15. Settings in settings/requestrules.ini. Code in kernel/private/classes/requestrules/. Command bin/php/ezrequestrules.php. Tests in tests/tests/kernel/classes/requestrules/.

The request rules decide what happens to a request just before its module view runs. A rule says when (conditions such as "typed as a system URL", "full view", "the user lacks a policy", "below node 864", "from this network") and what (redirect to the URL alias, not found, access denied, sign in first, redirect, rewrite, log). Rules are written in INI, can be switched on per siteaccess, can be extended in PHP by any extension, and are tied to role policies, so who is exempt is decided in the role editor, not in a file.

They were made for one question that settings could not answer before:

How do I stop anonymous visitors from opening pages by their system URL, /content/view/full/123, while the same page at its URL alias, /Fit-Healthy/Some-Article, keeps working for everyone?

The answer is one rule, shipped ready to use and switched on with two lines.

Read this page if you want to keep visitors off system URLs, protect a section, or run any check before a module view; and before you upgrade, if you rely on [SiteAccessRules] or on PolicyOmitList[].

In short

What changedNew request rules engine (settings/requestrules.ini, kernel/private/classes/requestrules/), asked before every module view runs. New policy function content/view_system_url. New command bin/php/ezrequestrules.php (list, check, explain). Three rules ship defined and off.
Who is affectedNobody until a rule is listed in RuleList[]. Then: every module view is asked, including PolicyOmitList[] views. [SiteAccessRules] keeps working and is checked first. Roles with content/* include the new policy function.
How to checkphp bin/php/ezrequestrules.php -s <siteaccess> --check
How to fixNothing to fix. To use it, follow the Quick start; remove any template redirect you added in full.tpl for the same purpose.

Contents

  1. Quick start
  2. Why settings and policies alone could not do it
  3. How a request is decided
  4. Writing rules
  5. Reference: conditions
  6. Reference: actions
  7. Reference: facts
  8. The policy function content/view_system_url
  9. Recipes
  10. Extending the rules in PHP
  11. Using the engine from PHP
  12. Checking, explaining and testing
  13. Caches
  14. Exponential Velocity and persistent workers
  15. Performance
  16. Security notes
  17. What the rules do not see
  18. Compatibility and upgrading
  19. Troubleshooting
  20. Files

1. Quick start

Goal: anonymous visitors (and anyone else without a particular policy) who type /content/view/full/<node> are sent, with a 301, to the node's URL alias. A node without an alias of its own answers 404. Administrators and anyone you grant the policy keep using system URLs.

Step 1. Switch the shipped rule on for each public siteaccess, in settings/siteaccess/<siteaccess>/requestrules.ini.append.php:

<?php /* #?ini charset="utf-8"?

[RequestRuleSettings]
RuleList[]
RuleList[]=system_url_full_view_to_alias

*/ ?>

Leave admin and editor siteaccesses alone: editors use system URLs there on purpose.

Step 2. Grant the policy content / view_system_url to the roles that should keep system URLs on the public siteaccesses, such as an Editor role (see section 8). Administrators already have it through content/* or */*.

Step 3. Deploy and check:

./console exp:velocity deploy --no-autoload --allow-root-user   # clears the INI and page caches, restarts Velocity
php bin/php/ezrequestrules.php -s site --check --allow-root-user
php bin/php/ezrequestrules.php -s site --uri=content/view/full/2 --allow-root-user
curl -sk -o /dev/null -w '%{http_code} %{redirect_url}\n' https://www.example.com/content/view/full/2

Without Velocity, the INI and page caches still need clearing: php bin/php/ezcache.php --clear-tag=ini --allow-root-user, then --clear-id=content,exphttpcache.

What it looks like on a reference installation (2026-10-02), on Apache and Velocity alike:

RequestAnonymousAdministrator
/content/view/full/2301 to /websites, Cache-Control: private, no-store200
/content/view/full/2/(offset)/10?x=1301 to /websites/(offset)/10?x=1200
/fit-healthy (the alias)200, cached as before200
/ (front page, IndexPage=/content/view/full/89)200200
/content/view/line/2 (another view mode)200200
/content/view/full/999999 (no such node)404404

That is all most sites need. The rest of this guide explains the mechanism completely: what can be decided and how, how to extend it, and how to tell what it is doing.

2. Why settings and policies alone could not do it

Before the request rules existed there were four tools, and none could express "this view, reached this way, for these users":

ToolWhat it doesWhy it is not the answer
Role policy content/read, limited by Class, Section, Owner, Group, Node, Subtree, StateDecides which content a user may readNot which view mode or which URL. Take content/read away and the content disappears everywhere: at its alias, in lists, in fetches on other pages.
site.ini [SiteAccessRules] Rules[] (access;disable, module;content/view)Switches a module or module/view off for a siteaccessIt applies to every user. It is checked after the URL alias is translated, so switching off content/view takes every alias down with it: the whole site answers 404. And it cannot tell full from line.
site.ini [SiteAccessSettings] RequireUserLogin + AnonymousAccessList[]Login for a whole siteaccess, with exemptionsAll or nothing per siteaccess.
A redirect in a full.tpl templateWorks on one designThe full view has already been built, and perhaps cached, when the template runs. It belongs to one design, not the system.

The deciding fact is that a URL alias is content/view/full/<node>: the kernel translates /Fit-Healthy into content/view/full/89 before anything checks access. The kernel knew what the visitor typed (it keeps the address before translating it), but nothing used that. The request rules use it: the fact requested_via is system, alias, wildcard or index.

3. How a request is decided

 request
   |
   |-- served from a page cache?  (role-aware HTTP cache early exit, Velocity response cache)
   |       yes -> the stored page. Rule-dependent pages are never stored: section 13
   |
   v  the kernel (ezpKernelWeb::dispatchLoop)
   1. address typed kept                 typed_uri, e.g. "Fit-Healthy" or "content/view/full/123"
   2. URL alias / wildcard translation   "Fit-Healthy" -> "content/view/full/89"; requested_via = alias
      a moved alias                      -> 301 to its new address (no rule is asked)
   3. [SiteAccessRules]                  module or view switched off -> not found
   4. the module and view exist?         no -> not found
   5. role policies                      not allowed -> access denied / sign in
   6. REQUEST RULES                      the first rule whose conditions all match decides
   7. the module view runs               (unless step 6 decided otherwise)

What follows from that order:

  • Rules only ever take access away or send the visitor elsewhere. They are asked after the policies allowed the view, so no rule can open a view a policy closed. Even Action=allow only means "stop asking further rules".
  • Rules see the translated request. A rule about content/view applies to aliases too. Use requested_via to tell them apart.
  • The first rule that decides, decides. Order matters. Put exceptions (allow) before the wider rules they are exceptions to.
  • A rule that matches but cannot act passes on. redirect_to_alias on a node without an alias cannot act: the rule's FallbackAction runs, and without one the next rule is asked.
  • No rule matched? The request runs as it always has. An empty RuleList costs about 2.6 µs per request (section 15).
  • Every answer a rule gives, other than allow, is sent with Cache-Control: private, no-store. It depends on who asked, so no browser or shared cache may keep it. A browser that kept a 301 would go on redirecting a visitor after they signed in.

4. Writing rules

4.1 Where settings go

Never edit settings/requestrules.ini. Override it like any INI file:

FileScope
settings/override/requestrules.ini.append.phpevery siteaccess
settings/siteaccess/<name>/requestrules.ini.append.phpone siteaccess (overrides the above)
extension/<ext>/settings/requestrules.ini.append.phpfrom an active extension (the extension's rules, handlers and providers)

RuleList[] on its own line empties the list before the items that follow, so a siteaccess can replace the list it inherited. Leave that line out to add to it.

4.2 Anatomy of a rule

[RequestRuleSettings]
RuleList[]=members_area                    # switched on: asked, in this order

[Rule-members_area]                        # the rule: [Rule-<name>]
Description=Anonymous visitors of the members area sign in first.
Conditions[subtree]=864                    # ALL conditions must match
Conditions[user]=anonymous
Action=login                               # what happens then
ActionArgs[...]=...                        # arguments of the action, if it has any
FallbackAction=notfound                    # optional: when the action cannot act
FallbackArgs[...]=...

A rule group that exists but is not in RuleList[] (or given by a rule provider) is off. The shipped rules are written that way: defined, and off until a siteaccess lists them.

4.3 Condition values

FormMeaningExample
valuematches this valueConditions[view_mode]=full
a,b,cmatches any of them (spaces around commas are ignored)Conditions[siteaccess]=site, bold
!a,bmatches none of them: the whole condition turned aroundConditions[policy]=!content/view_system_url
pat*a shell pattern, where the condition says it takes patterns (*, ?, [a-z])Conditions[uri]=content/view/*
name:argument in the keya condition that needs a nameConditions[param:NodeID]=2, Conditions[header:X-Requested-With]=XMLHttpRequest

Matching is case-insensitive. A condition with an empty value (or only !) never matches, and --check reports it.

4.4 Several rules

RuleList[]
RuleList[]=robots_may_see_node_2      # 1. exception first
RuleList[]=system_url_full_view_to_alias   # 2. the general rule

[Rule-robots_may_see_node_2]
Conditions[node]=2
Conditions[requested_via]=system
Action=allow

The rules are asked in the order of RuleList[], followed by the rules from RuleProviders[], in their order.

4.5 Switching everything off

[RequestRuleSettings]
Enabled=false

No rule is asked, and the kernel behaves exactly as it did before this feature.

5. Reference: conditions

Stable means the answer is the same for every request that one stored page answers: the cache keys hold what the condition reads. A rule with a non-stable condition keeps the pages it could decide out of the shared caches (section 13). Prefer stable conditions where you can.

ConditionReads (fact)ValuesPatternsStableExample
requested_viarequested_viasystem (typed as module/view), alias (a URL alias), wildcard (a URL wildcard), index (the front page, IndexPage)noyesConditions[requested_via]=system
urityped_uri: the address typed, without siteaccess, slashes or queryaddressesyesyesConditions[uri]=content/view/*
resolved_uriresolved_uri: the system address that runsaddressesyesyesConditions[resolved_uri]=content/view/full/89
modulemodulemodule namesnoyesConditions[module]=content,shop
module_viewmodule/viewmodule/viewyesyesConditions[module_view]=content/*
view_modeview_mode: the ViewMode of content/viewview modesnoyesConditions[view_mode]=full
param:<Name>params[<Name>]: a view parameter by its name in the module's module.phpvaluesyesyesConditions[param:NodeID]=2,43
nodenode_id: the node of content/viewnode idsnoyesConditions[node]=2
subtreepath_ids: the node and its ancestorsnode idsnoyesConditions[subtree]=864
classclass_identifier of the nodeclass identifiersnoyesConditions[class]=article,blog_post
sectionsection_id, section_identifier of the objectids or identifiersnoyesConditions[section]=members
statestate_identifiers: group/state of the objectidentifiersyesyesConditions[state]=ez_lock/locked
useris_anonymousanonymous, logged_innoyesConditions[user]=anonymous
rolerole_names: roles assigned to the user or their groupsrole namesnoyesConditions[role]=Partner
policyhas_access: eZUser::hasAccessTo(); limited access countsmodule/functionnoyesConditions[policy]=!content/view_system_url
siteaccesssiteaccesssiteaccess namesyesyesConditions[siteaccess]=site,bold*
methodmethodGET, POST, HEAD, ...noyesConditions[method]=!POST
schemeschemehttp, httpsnoyesConditions[scheme]=http
hosthost: the host name, no porthost namesyesyesConditions[host]=staging.*
user_iduser_id: the user's object ididsnonoConditions[user_id]=14
user_loginuser_loginloginsyesnoConditions[user_login]=svc-*
groupgroup_ids: user group object ids of the useridsnonoConditions[group]=12
header:<Name>a request header; * = sent at allvaluesyesnoConditions[header:X-Requested-With]=XMLHttpRequest
ipclient_ipaddresses and CIDR networks, IPv4 and IPv6nonoConditions[ip]=10.0.0.0/8, 2001:db8::/32
fact:<name>any fact, including an extension'svaluesyesnoConditions[fact:customer_tier]=gold

user_id, user_login and group are not stable because the role-aware HTTP cache shares one stored page between all users with the same roles.

Conditions about the node (node, subtree, class, section, state) never match a request that is not about a node. Their facts are null then, and a negated one (!article) does not match either.

6. Reference: actions

ActionAnswerArgumentsCannot act (falls back) when
allowthe view runs; no later rule is askednonenever
notfoundthe "not found" page, HTTP 404 (kernel error 3). Says nothing about whether the page existsnonenever
forbiddenthe "access denied" page, HTTP 403 (kernel error 1), as a missing policy givesnonenever
loginanonymous: 302 to user/login, and back to the page after signing in. Signed in: "access denied"nonethe request is a sign-in view itself (user/login, logout, register, activate, success, forgotpassword, password), so no rule can make a loop
redirectHTTP redirectto (required; placeholders), status 301, 302 (default), 303, 307, 308to is empty
redirect_to_aliasHTTP redirect to the URL alias of the node, with the view's user parameters (/(offset)/10); the kernel keeps the query stringstatus (default 301)not about a node, or the node has no alias of its own
rewritethe kernel runs another address instead, without a redirect; the visitor keeps the address they typed. The rules are asked again for the new address, at most MaxRewrites (3) times, and then "not found"to (required; placeholders)to is empty
logwrites a line to var/log/requestrules.log; always passes on (to the fallback, or the next rule)message (placeholders; default: the address)always: that is its purpose

A redirect target can be a path inside the siteaccess (/about-us), a system address (/content/view/full/2, sent as that node's alias, as every kernel redirect is), or an absolute URL. An absolute URL on another host must be listed in site.ini [SiteSettings] AllowedRedirectHosts[]; otherwise the kernel refuses it with 403.

Placeholders

to and message can use facts: {node_id}, {typed_uri}, {view_mode}, {siteaccess}, {user_login}, any scalar fact {name}, a view parameter {param:NodeID}, a header {header:Host}. An unknown placeholder, or one whose fact is not a single value, becomes empty.

log with a fallback: audit, then act

Action=log
ActionArgs[message]=refused {typed_uri} for {user_login}
FallbackAction=notfound

This writes the line, then answers "not found": one rule both records and refuses.

7. Reference: facts

Facts are what conditions and actions read. The kernel defines them for every request (ezpRequestRuleKernel::context()). Most are resolved only when a rule reads them: a rule about the address never loads the node, and the node is loaded at most once however many rules ask.

FactTypeExampleCost
typed_uristringcontent/view/full/2, fit-healthy, `` (front page)free
requested_viastringsystem, alias, wildcard, indexfree
resolved_uristringcontent/view/full/89free
module, viewstringcontent, viewfree
paramsarray{"ViewMode":"full","NodeID":"89"}free
view_modestring|nullfullfree
node_idint|null89free
user_parametersarray{"offset":"10"}free
user_parameters_uristring/(offset)/10free
rewritesintrewrites so far in this requestfree
siteaccess, methodstringsite, GETfree
scheme, host, client_ip, headersstring / arrayhttps, www.example.com, 10.1.2.3, lower-case nameson use
usereZUserthe current userfree
user_idint10 (anonymous)free
is_anonymous, user_login, group_ids, role_nameson use
has_accesscallable( module, function ) : boolon use
node, objecteZContentObjectTreeNode / eZContentObjecton use, one fetch
url_alias, path_ids, class_identifierfrom the nodefit-healthy, [1,2,89], frontpageon use
section_id, section_identifier, state_identifiersfrom the object1, standard, ["ez_lock/not_locked"]on use
rules_vary_by_requestboolset by the engine (section 13)

An extension adds facts with a fact provider (10.3). A resolver that throws is written to the debug output and gives null, so one broken fact never breaks a page.

8. The policy function content/view_system_url

A new function of the content module. It has no limitations and is checked by no view. It exists so that who is exempt from a system-URL rule is a role decision:

Conditions[policy]=!content/view_system_url     # the rule applies to users WITHOUT it
  • Administrators have it through content/* (all functions of the content module) or */*.
  • Anonymous does not have it unless you add it, so leave it out.
  • Editors who work on the public siteaccess with system URLs need it: in the admin, open Roles and policies, then the role (e.g. Editor), New policy, module content, function view_system_url, Grant full access.

The same pattern works with any policy, including your own module's functions: Conditions[policy]=!mymodule/premium.

9. Recipes

Each recipe is complete: put the group in requestrules.ini.append.php and add the name to RuleList[]. Node ids, class identifiers and addresses are examples. Check yours with bin/php/ezrequestrules.php.

9.1 System URLs to the alias (shipped: system_url_full_view_to_alias)

[Rule-system_url_full_view_to_alias]
Conditions[requested_via]=system
Conditions[module_view]=content/view
Conditions[view_mode]=full
Conditions[policy]=!content/view_system_url
Action=redirect_to_alias
ActionArgs[status]=301
FallbackAction=notfound

9.2 Strict: no system URL of any view mode (shipped: system_url_content_view_notfound)

[Rule-system_url_content_view_notfound]
Conditions[requested_via]=system
Conditions[module_view]=content/view
Conditions[policy]=!content/view_system_url
Action=notfound

Mind the views your design fetches by URL (for example an AJAX call to content/view/line/<node>). Exempt them first (9.11), or keep 9.1.

9.3 Audit first, then enforce (shipped: system_url_full_view_audit)

Put the audit rule before the rule it previews. It logs and passes on:

RuleList[]
RuleList[]=system_url_full_view_audit
$ tail var/log/requestrules.log
[ Oct 02 2026 06:01:12 ] [] [system_url_full_view_audit] GET system URL content/view/full/2 (node 2) by anonymous ip=10.1.2.3

When the log shows only what you expect, add the enforcing rule after it, or replace it.

9.4 A members area: sign in first

[Rule-members_area]
Description=Below node 864, anonymous visitors sign in first and come back.
Conditions[subtree]=864
Conditions[user]=anonymous
Action=login

Policies still decide what members may read; this rule only sends anonymous visitors to the login page instead of a bare "access denied".

9.5 An intranet section, only from the office networks

[Rule-intranet_from_office_only]
Conditions[section]=intranet
Conditions[ip]=!10.0.0.0/8, 192.168.0.0/16, 2001:db8:42::/48
Action=notfound

Behind a load balancer, set site.ini [HTTPHeaderSettings] ClientIpByCustomHTTPHeader=X-Forwarded-For so client_ip is the visitor, not the balancer. Because ip varies by request, intranet pages are not stored in the shared caches.

9.6 Never show a class to anonymous visitors

[Rule-internal_memos_hidden]
Conditions[class]=internal_memo
Conditions[user]=anonymous
Action=notfound

9.7 Move a whole branch: old addresses to new ones

[Rule-old_news_to_archive]
Conditions[subtree]=1200
Conditions[requested_via]=alias
Action=redirect
ActionArgs[to]=/archive/node/{node_id}
ActionArgs[status]=308

For addresses that no longer resolve to any node, use URL wildcards (content/urlwildcards). Rules only see addresses that reach a module view (section 17).

9.8 Maintenance: everyone but administrators sees the maintenance page

RuleList[]
RuleList[]=maintenance_admins_through
RuleList[]=maintenance_page

[Rule-maintenance_admins_through]
Conditions[role]=Administrator
Conditions[module]=content,user,ezjscore
Action=allow

[Rule-maintenance_page]
Conditions[module]=content
Conditions[node]=!555
Action=rewrite
ActionArgs[to]=content/view/full/555

Node 555 is the maintenance page. user/login keeps working, so administrators can still sign in.

9.9 A view mode only for scripts (AJAX)

[Rule-ajax_view_only_by_xhr]
Conditions[view_mode]=ajax
Conditions[header:X-Requested-With]=!XMLHttpRequest
Action=notfound

A header is never authentication. This keeps casual visitors and crawlers off a fragment view; it does not protect data.

9.10 A view that must be posted, never opened

[Rule-action_only_by_post]
Conditions[module_view]=content/action
Conditions[method]=!POST
Action=notfound

9.11 An exception before a wider rule

RuleList[]
RuleList[]=line_view_for_the_menu_script
RuleList[]=system_url_content_view_notfound

[Rule-line_view_for_the_menu_script]
Conditions[module_view]=content/view
Conditions[view_mode]=line
Conditions[header:X-Requested-With]=XMLHttpRequest
Action=allow

9.12 A staging host behind a login

[Rule-staging_requires_login]
Conditions[host]=staging.*
Conditions[user]=anonymous
Conditions[module]=!user
Action=login

9.13 Partner pages for partners, a teaser for everyone else

[Rule-partner_pages]
Conditions[subtree]=900
Conditions[role]=!Partner,Administrator
Action=redirect
ActionArgs[to]=/become-a-partner
ActionArgs[status]=302

9.14 Retire a page but keep its address

[Rule-retired_offer_shows_successor]
Conditions[node]=123
Action=rewrite
ActionArgs[to]=content/view/full/456

The visitor stays on the old address and sees node 456.

9.15 Different rules per siteaccess

settings/siteaccess/site/requestrules.ini.append.php:

[RequestRuleSettings]
RuleList[]
RuleList[]=system_url_full_view_to_alias

settings/siteaccess/bold_ger/requestrules.ini.append.php:

[RequestRuleSettings]
RuleList[]
RuleList[]=system_url_content_view_notfound

The same rules can also live in one file with Conditions[siteaccess]=....

9.16 HTTPS only for signed-in users

[Rule-signed_in_over_https]
Conditions[scheme]=http
Conditions[user]=logged_in
Action=redirect
ActionArgs[to]=https://{host}/{typed_uri}
ActionArgs[status]=308

(Add the host to AllowedRedirectHosts[] if it is not the current one; it is, here.)

10. Extending the rules in PHP

An extension adds conditions, actions, facts and rules. Each is a small class named in the extension's extension/<ext>/settings/requestrules.ini.append.php and found by the autoloader. Run php bin/php/ezpgenerateautoloads.php -e after adding the classes.

The examples build one extension, myshop, that keeps premium articles for paying customers.

10.1 A condition

<?php
// extension/myshop/classes/myshopcustomertiercondition.php
/**
 * customer_tier: the tier of the signed-in customer, from the fact
 * 'customer_tier' (myShopFactProvider): Conditions[customer_tier]=gold,silver
 */
class myShopCustomerTierCondition implements ezpRequestRuleCondition
{
    public function matches( ezpRequestContext $context, array $values, $argument )
    {
        $tier = $context->get( 'customer_tier' );     // resolved on first use
        foreach ( $values as $value )
        {
            if ( strcasecmp( $value, (string)$tier ) === 0 )
                return true;
        }
        return false;
    }
}

Rules for a condition:

  • Read only facts. Never touch the database directly in matches(). Put that in a fact, so it is loaded once, lazily, and can be given in tests.
  • Never change anything. A condition may be asked several times, or not at all.
  • $values is never empty, and the "!" is already handled. Answer "does any value match?".
  • Stable? Add implements ezpRequestRuleCondition, ezpRequestRuleStableCondition only when the answer depends on nothing but the cache keys (address, siteaccess, host, scheme, the node, the user's roles and policies). A customer tier is per user, so not stable: leave it out, and pages it could decide stay out of the shared caches.

A condition that compares one fact can be just this:

class myShopRegionCondition extends ezpRequestFactCondition { protected $fact = 'region'; protected $patterns = true; }

10.2 An action

<?php
// extension/myshop/classes/myshoppaywallaction.php
/**
 * paywall: shows the paywall page (design:myshop/paywall.tpl) in place of the
 * article, with HTTP 402. ActionArgs[template]=design:myshop/paywall.tpl
 */
class myShopPaywallAction implements ezpRequestRuleAction
{
    public function decide( ezpRequestContext $context, array $arguments, ezpRequestRule $rule )
    {
        $node = $context->get( 'node' );
        if ( !$node instanceof eZContentObjectTreeNode )
            return null;                                   // cannot act: fallback / next rule

        $tpl = eZTemplate::factory();
        $tpl->setVariable( 'node', $node );
        header( 'HTTP/1.1 402 Payment Required' );
        return ezpRequestRuleResult::moduleResult( array(
            'content' => $tpl->fetch( isset( $arguments['template'] ) ? $arguments['template'] : 'design:myshop/paywall.tpl' ),
            'path' => array( array( 'text' => $node->attribute( 'name' ), 'url' => false ) ),
            'node_id' => $node->attribute( 'node_id' ),
        ) );
    }
}

An action decides. It returns one of the ezpRequestRuleResult answers and the kernel sends it:

ConstructorAnswer
ezpRequestRuleResult::allow()run the view; ask no further rule
::notFound(), ::forbidden(), ::login()as the built-in actions
::redirect( $location, $status )redirect; $status 301/302/303/307/308
::rewrite( $uri )run $uri instead
::moduleResult( array )this module result is the page, inside the pagelayout
nullcannot act here: the fallback runs, or the next rule

10.3 A fact provider

<?php
// extension/myshop/classes/myshopfactprovider.php
class myShopFactProvider implements ezpRequestFactProvider
{
    public function defineFacts( ezpRequestContext $context )
    {
        // A resolver: runs only if a rule reads 'customer_tier', at most once
        $context->define( 'customer_tier', function ( ezpRequestContext $c )
        {
            $user = $c->get( 'user' );
            return $user && !$c->get( 'is_anonymous' ) ? myShopCustomer::tierOf( $user->id() ) : 'none';
        } );
        $context->define( 'is_premium', function ( ezpRequestContext $c )
        {
            $object = $c->get( 'object' );
            return $object ? myShopPremium::isPremium( $object->attribute( 'id' ) ) : false;
        } );
    }
}

Facts can be read by built-in conditions too: Conditions[fact:is_premium]=1.

10.4 A rule provider (rules from a database, another file, an API)

<?php
// extension/myshop/classes/myshoprulesprovider.php
class myShopRulesProvider implements ezpRequestRuleProvider
{
    public function rules()
    {
        $rules = array();
        foreach ( myShopEmbargo::fetchActive() as $embargo )        // e.g. rows of a table
        {
            $rules[] = new ezpRequestRule(
                'embargo_' . $embargo->attribute( 'id' ),
                array( 'node' => (string)$embargo->attribute( 'node_id' ), 'policy' => '!myshop/embargo_bypass' ),
                'notfound',
                array(), null, array(),
                'Embargoed until ' . date( 'c', $embargo->attribute( 'until' ) )
            );
        }
        return $rules;
    }
}

Providers run once per siteaccess and process (the engine is kept; see section 14). Rules that change during the day belong in a fact (Conditions[fact:embargoed]=1, read per request), not in a provider.

10.5 Registering it all

<?php /* #?ini charset="utf-8"?
# extension/myshop/settings/requestrules.ini.append.php

[RequestRuleSettings]
ConditionHandlers[customer_tier]=myShopCustomerTierCondition
ActionHandlers[paywall]=myShopPaywallAction
FactProviders[]=myShopFactProvider
RuleProviders[]=myShopRulesProvider
RuleList[]=premium_paywall

[Rule-premium_paywall]
Description=Premium articles for gold and silver customers; everyone else sees the paywall.
Conditions[class]=article
Conditions[fact:is_premium]=1
Conditions[customer_tier]=!gold,silver
Action=paywall
ActionArgs[template]=design:myshop/paywall.tpl
*/ ?>
php bin/php/ezpgenerateautoloads.php -e
php bin/php/ezrequestrules.php -s site --check --allow-root-user

10.6 Testing your extension's rules without a database

The engine takes its settings as an array, and a context is just facts:

$engine = new ezpRequestRuleEngine( array(
    'rules' => array( 'premium_paywall' => array(
        'Conditions' => array( 'class' => 'article', 'fact:is_premium' => '1', 'customer_tier' => '!gold,silver' ),
        'Action' => 'paywall' ) ),
    'condition_handlers' => array( 'customer_tier' => 'myShopCustomerTierCondition' ),
    'action_handlers' => array( 'paywall' => new myShopPaywallActionStub() ),
) );
$result = $engine->evaluate( new ezpRequestContext( array(
    'class_identifier' => 'article', 'is_premium' => true, 'customer_tier' => 'free',
) ) );
$this->assertSame( ezpRequestRuleResult::MODULE_RESULT, $result->type );
$this->assertSame( array(), $engine->validate() );

tests/tests/kernel/classes/requestrules/ezpRequestRuleEngineTest.php shows every built-in condition and action tested this way.

11. Using the engine from PHP

$engine = ezpRequestRuleEngine::instance();     // the current siteaccess's rules

// Add a rule at run time (idempotent by name: adding it again replaces it)
$engine->addRule( new ezpRequestRule( 'tmp_block', array( 'node' => '123' ), 'notfound' ) );
$engine->addRule( new ezpRequestRule( 'tmp_exception', array( 'node' => '123', 'role' => 'Editor' ), 'allow' ), 'tmp_block' );  // before tmp_block
$engine->removeRule( 'tmp_block' );

// Decide about any request, e.g. in a custom module or a script
$context = ezpRequestRuleKernel::context( 'content', 'view', array( 'full', '123' ),
                                          array( 'typed_uri' => 'content/view/full/123', 'via' => 'system' ),
                                          eZUser::currentUser() );
$result = $engine->evaluate( $context );       // ezpRequestRuleResult, or null: no rule decides
if ( $result ) echo $result->rule, ': ', $result->describe();

// Why
foreach ( $engine->explain( $context ) as $row )
    printf( "%s %s %s\n", $row['decides'] ? 'DECIDES' : ( $row['matched'] ? 'matched' : 'no' ), $row['rule'], json_encode( $row['conditions'] ) );

// Problems in the settings, as --check prints them
print_r( $engine->validate() );

ezpRequestRuleEngine::resetInstance();          // re-read requestrules.ini on the next instance()

Rules added with addRule() live as long as the engine: one PHP-FPM request, or the life of a Velocity worker. Use them for tests and scripts. Rules that must always apply belong in settings or a rule provider.

12. Checking, explaining and testing

12.1 bin/php/ezrequestrules.php

php bin/php/ezrequestrules.php -s site --list   --allow-root-user   # the rules, in order
php bin/php/ezrequestrules.php -s site --check  --allow-root-user   # problems; exit code 1 when any (for CI)
php bin/php/ezrequestrules.php -s site --uri=content/view/full/2 --allow-root-user
php bin/php/ezrequestrules.php -s site --uri=content/view/full/2 --user=admin --allow-root-user
php bin/php/ezrequestrules.php -s site --uri=fit-healthy --ip=10.1.2.3 --header=X-Requested-With:XMLHttpRequest --method=POST --allow-root-user

--uri translates the address the way the kernel does (alias, wildcard, front page), so the explanation is the one a real request gets. It also says when the policies would refuse the view before any rule is asked.

Real output (reference installation, 2026-10-02):

Address  /content/view/full/2  ->  content/view/full/2  (requested via system)
User     anonymous (anonymous)  siteaccess site

DECIDES system_url_full_view_to_alias
        yes  requested_via = system
        yes  module_view = content/view
        yes  view_mode = full
        yes  policy = !content/view_system_url
        -> redirect 301 to /websites

Result   decided by the rule marked DECIDES
Facts the rules read:
  node_id              2
  requested_via        'system'
  typed_uri            'content/view/full/2'
  url_alias            'websites'
  ...

--check catches:

  • a rule in RuleList[] without its group;
  • a rule without conditions or action;
  • unknown condition and action names;
  • empty values;
  • param, header and fact without their :name;
  • policy values that are not module/function;
  • redirect and rewrite without to;
  • statuses that are not redirects;
  • handler and provider classes that do not exist or implement the wrong interface.

Run it in your deployment pipeline. A rule with a typo never matches; it does not fail the site, so --check is how a typo is found.

12.2 At run time

  • Debug output (DebugOutput=enabled): each decision is a notice, Request rule 'name': redirect 301 to /x for content/view/full/2. Unknown conditions and actions are errors.
  • $GLOBALS['ezpRequestRuleDecision']: the ezpRequestRuleResult that decided this request, or null. Use it in a pagelayout operator, or a response listener.
  • Action=log: var/log/requestrules.log.

12.3 Tests

php vendor/bin/phpunit tests/tests/kernel/classes/requestrules/    # 21 tests: every condition, action, the shipped file

The same 21 tests run with the rest of the kernel tests (--testsuite kernel-classes).

By use, over HTTP, against a running site: anonymous and administrator, every action, Apache and Velocity. Verified 2026-10-02 on a reference installation, all PASS on both servers:

CheckApacheVelocity
anonymous system URL → 301 to the alias, private, no-storePASSPASS
user parameters and query keptPASSPASS
alias, front page, line view left alonePASSPASS
administrator keeps the system URL (200)PASSPASS
allow before the shipped rulePASSPASS
forbidden 403, notfound 404PASSPASS
redirect 307 to a system target, sent as its aliasPASSPASS
rewrite keeps the address (200)PASSPASS
a rewrite loop stops after MaxRewrites (404)PASSPASS
log writes its line and passes onPASSPASS
login → 302 to user/login, and back to the page after signing inPASSPASS
unaffected pages are still served from the page caches (X-Exp-Cache: HIT)PASSPASS

13. Caches

Two caches answer requests before the kernel runs, so before any rule is asked: the role-aware HTTP cache (its early exit in config.php) and Velocity's response cache. The rules are safe with them because of three guarantees:

  1. No answer a rule gives other than allow is stored. It is sent with Cache-Control: private, no-store, which Velocity, proxies and browsers honour, and the HTTP cache refuses it (X-Exp-Cache: BYPASS (request rule)). A redirect for anonymous visitors can therefore never be served to an editor, and a browser does not keep a 301 that would still apply after signing in.
  2. A page whose rules read only stable conditions is cached as before. Two requests that one stored page answers have the same address, siteaccess, host, scheme and permission context, so the rules decide the same for both. The shipped rules are all stable: they do not reduce caching at all (on the reference installation, /fit-healthy stays a HIT).
  3. A page that a rule with a request-varying condition could decide is never stored. If every stable condition of such a rule matches, another request for the same page with a different header, address or user could be decided otherwise. So the page is sent private, no-store even when the view runs, and the context fact rules_vary_by_request is true. Stable conditions are asked first, so a rule like module_view=content/view + header:X-Test=deny only affects content/view pages.

After changing rules, clear the page caches. A page stored before a rule existed is served without asking it. exp:velocity deploy does this; otherwise run php bin/php/ezcache.php --clear-id=content,exphttpcache --allow-root-user and ./console exp:velocity cache clear --apply.

The content view cache (ViewCaching) stores a node's rendered view, not the response. It runs after the rules and is unaffected by them.

14. Exponential Velocity and persistent workers

  • The engine is kept per siteaccess for the life of the worker: requestrules.ini is read once per siteaccess, rule providers run once, and handlers are created once. The context is new for every request.
  • Per-request state is reset by the kernel at the start of every request ($GLOBALS['ezpRequestRuleNoStore'], $GLOBALS['ezpRequestRuleDecision'], the rewrite counter). One request's decision can never leak into the next.
  • addRule() is idempotent by name, so code that adds a rule on every request does not pile rules up in a long-running worker.
  • A settings change reaches Velocity only after a restart. Workers keep the INI they loaded. Deploy with ./console exp:velocity deploy --no-autoload --allow-root-user, which clears the INI cache, restarts Velocity and clears the page caches, in that order. PHP-FPM reads settings again once the INI cache is cleared.
  • New condition, action or provider classes need the extension autoloads regenerated (exp:velocity deploy does it), and kernel classes need --kernel.

15. Performance

Measured on a reference installation (PHP 8.5, 20 000 evaluations each, without the kernel; the cost includes building the context):

Rulesµs per request
none (the default)2.6
the shipped rule, an alias it does not apply to9.7
the shipped rule deciding (system URL, reads the node's alias)23.3
20 rules, none applies110.5

A rendered page costs about 280 ms on the same server, so even 20 rules are 0.04 % of it. A page served from a cache never reaches the rules at all. The figures come from evaluating each case 20 000 times in a loop with a prepared context, the way the unit tests build one.

Keep rules cheap by ordering conditions from cheap to expensive within a rule. Every condition must match, and evaluation stops at the first one that does not. Address, module, view and user conditions cost nothing; node conditions load the node once.

16. Security notes

  • Rules add restrictions; policies grant access. Never rely on a rule to protect data that a policy allows. The rule may be switched off, listed in the wrong siteaccess, or bypassed by a view nobody thought of (section 17). Protect data with content/read limitations; use rules for how allowed content may be reached.
  • Fails safe for the site, loud for the operator. An unknown condition never matches and an unknown action never decides, so a typo cannot take the site down. Run --check to catch typos.
  • Headers and client addresses come from the client. header:* is a convenience, never authentication. ip is only as trustworthy as ClientIpByCustomHTTPHeader: set it only behind a proxy that overwrites that header.
  • Redirects stay on allowed hosts. An absolute target on another host must be in AllowedRedirectHosts[]; the kernel refuses anything else. Placeholders can carry what the visitor sent ({typed_uri}, {host}, {header:...}); the allowed-hosts check applies to the result all the same, so a target built from them cannot leave your hosts.
  • notfound gives nothing away. It is the same page as for a node that does not exist. Use it rather than forbidden when the existence of a page is itself confidential.
  • No answer depending on who asked is cached (section 13).

17. What the rules do not see

  • Addresses that never reach a module view:

    • a module that does not exist ("not found" before any rule);
    • a moved URL alias (the kernel redirects to the new address first);
    • views switched off by [SiteAccessRules];
    • views the policies refuse.

    To catch addresses that do not exist, use URL wildcards.

  • Pages served from a page cache. By design, as section 13 explains.

  • The query string. No fact holds it (use a fact provider if you need it). User parameters (/(offset)/10) are facts.

  • Other entry points: the REST API kernel, index_treemenu.php, static files and image aliases served by the web server, and CLI scripts.

  • Templates. A node embedded in another page (node_view_gui, fetch) is not a request; content/read governs it.

18. Compatibility and upgrading

  • Nothing changes until a rule is listed. RuleList[] is empty in settings/requestrules.ini; the three shipped rules are defined and off.
  • [SiteAccessRules] keeps working exactly as before, and is checked before the request rules (section 3, step 3).
  • PolicyOmitList[] views are asked too. The rules run for every module view that runs, whether its policy is checked or omitted.
  • The policy function content/view_system_url appears in the role editor; existing roles are unaffected. Roles with content/* include it.
  • Class names start with ezp, as the kernel's other private classes do; they are stable API from 6.0.15 on: ezpRequestRuleEngine, ezpRequestRule, ezpRequestRuleResult, ezpRequestContext, ezpRequestRuleCondition, ezpRequestRuleStableCondition, ezpRequestRuleAction, ezpRequestRuleProvider, ezpRequestFactProvider, ezpRequestFactCondition, ezpRequestRuleKernel. Built-in condition and action classes may gain siblings; their names and behaviour stay.
  • The template-redirect workaround can go. Remove a redirect you put in full.tpl for this purpose once the rule is on.

19. Troubleshooting

SymptomCauseFix
The rule does nothingnot in RuleList[] for this siteaccess; INI cache; Velocity not restarted; page served from a cache stored before the rule--list -s <sa>; exp:velocity deploy --no-autoload; clear content,exphttpcache
--uri says DECIDES, the browser says 200the browser or a cache kept an older answertry curl -sk -o /dev/null -w '%{http_code}'; clear the page caches
The whole site answers 404a rule without a narrowing condition (module_view / requested_via), e.g. only Conditions[policy]=!...--uri= on an alias shows which rule decides; narrow it
Editors are redirected away from system URLs on the public sitetheir role lacks content/view_system_urlgrant it (section 8)
ERR_TOO_MANY_REDIRECTSa redirect whose target matches the same rule (for example to the same address)add a condition the target does not meet, or use redirect_to_alias, which never redirects to itself
Rewrites end in 404the rewritten address matched again; MaxRewrites reachedexclude the target (Conditions[node]=!555)
Pages of one section are no longer cacheda rule there uses header, ip, user_id, user_login, group, fact or a custom condition without the stable markexpected (section 13); move it to a stable condition if you can
--check: "is not a class implementing"the class is missing from the autoloads, or implements the wrong interfacephp bin/php/ezpgenerateautoloads.php -e
A decision is not in var/log/requestrules.logonly Action=log writes thereenable debug output, or add a log rule in front

20. Files

FileWhat
settings/requestrules.inisettings, the three shipped rules (off)
kernel/private/classes/requestrules/ezprequestruleengine.phpthe engine: rules, handlers, evaluate(), explain(), validate()
kernel/private/classes/requestrules/ezprequestrule.phpone rule; parsing of keys and values
kernel/private/classes/requestrules/ezprequestcontext.phpthe facts of a request, lazily resolved
kernel/private/classes/requestrules/ezprequestruleresult.phpthe decisions
kernel/private/classes/requestrules/ezprequestruleinterfaces.phpezpRequestRuleCondition, ezpRequestRuleStableCondition, ezpRequestRuleAction, ezpRequestRuleProvider, ezpRequestFactProvider
kernel/private/classes/requestrules/ezprequestruleconditions.phpthe built-in conditions
kernel/private/classes/requestrules/ezprequestruleactions.phpthe built-in actions
kernel/private/classes/requestrules/ezprequestrulekernel.phpthe kernel side: facts of a real request, the response for a decision
kernel/private/classes/ezpkernelweb.phprecords how the request was reached; asks the rules before the view runs
kernel/private/classes/httpcache/ezphttpcachelistener.phpdoes not store a page the rules keep out of shared caches
kernel/content/module.phpthe policy function content/view_system_url
bin/php/ezrequestrules.phplist, check, explain
tests/tests/kernel/classes/requestrules/ezpRequestRuleEngineTest.php21 unit tests

Something wrong on this page? Report an error

Documentation license

This documentation is free documentation, licensed under the GNU Free Documentation License 1.3 or later.

Exponential Copyright © 1998-2026 Exponential Foundation and 7x