zeix / craft-entry-type-policy
Restrict which user groups may create entries of a given entry type, and how many entries of that type a section may hold — per site. Rules are editable in the control panel or version-controlled in a config file.
Package info
github.com/zeixcom/craft-entry-type-policy
Type:craft-plugin
pkg:composer/zeix/craft-entry-type-policy
Requires
- php: >=8.2
- craftcms/cms: ^5.8.0
Requires (Dev)
- craftcms/ecs: dev-main
- craftcms/phpstan: dev-main
README
Restrict which user groups may create entries of a given entry type, and how many entries of that type a section may hold — per site.
Craft lets you say who can edit a section. It doesn't let you say "only the web team may create a Landing Page", or "there is exactly one News Overview per site". This plugin does, without a custom module.
Requirements
Craft CMS 5.8.0 or later, PHP 8.2 or later.
Installation
composer require zeix/craft-entry-type-policy php craft plugin/install entry-type-policy
How rules work
A rule is one row. It names a section and an entry type, and then restricts one or both of:
| Setting | Effect |
|---|---|
| User groups | Only these groups may create entries of this type. Leave blank for anyone. |
| Limit | At most this many entries of this type in the section. Leave blank for unlimited. |
Entry types with no rule stay unrestricted. A barred type is:
- dropped from the entry type switcher in the editor,
- never chosen as the default type when an editor clicks "New entry",
- refused on save — which is what also covers GraphQL, the Element API, and a hand-typed
?type=URL.
Editing an entry that already has a barred type stays allowed, so tightening a rule never locks anyone out of existing content.
Admins bypass User groups but not Limit: a second overview page breaks routing no matter who created it.
Multisite
Three settings, in increasing order of fussiness.
Restrict a rule to certain sites
Leave Sites blank and the rule applies everywhere. Name sites and it applies only there.
// German and Italian editors need permission for service pages. French doesn't. ['section' => 'pages', 'entryType' => 'servicesPage', 'sites' => ['de', 'it'], 'userGroups' => ['superUsers']],
Count the limit per site
Limit counts decides what "one" means:
- Across all sites (default) — an entry propagated to five sites counts once. One News Overview for the whole install.
- Per site — one News Overview in each site. This is usually what you want for a localised landing or overview page.
['section' => 'pages', 'entryType' => 'newsOverview', 'limit' => 1, 'limitPer' => 'site'],
On a single-site install the two are identical, which is why Across all sites is the default.
Override a rule for one site
Add a second row for the same section and entry type, and name the site. The site-specific row wins, and inherits anything it leaves blank from the general row:
['section' => 'pages', 'entryType' => 'newsOverview', 'sites' => ['fr'], 'limit' => 3], ['section' => 'pages', 'entryType' => 'newsOverview', 'limit' => 1, 'userGroups' => ['superUsers'], 'limitPer' => 'site'],
French gets three overview pages; every other site gets one. Both still require the superUsers group, because the French row didn't say otherwise.
Rules are matched top-down: among rows of equal specificity, the first one wins.
What is not guarded
Propagation. Rules govern where an entry may be created; refusing a propagating save would leave the entry half-written across sites. If an editor may create a type in the German site, it will still propagate to the French site normally.
Known limitation
When a rule bars every entry type a section has, that section's "New entry" button stays enabled and clicking it appears to do nothing. The save is refused correctly — HTTP 400 with the error on typeId — but Craft's element index calls entries/create without a .catch(), so nothing is shown to the editor.
The button can't simply be hidden from a plugin: it's built client-side from Craft.publishableSections, and EntriesController::actionCreate() reads getAvailableEntryTypes()[0], so handing Craft an empty list is a fatal error rather than a hidden button.
Sections with more than one entry type are unaffected: the barred type is simply never the one you land on.
Configuring rules
In the control panel, open Entry Type Policy in the main CP nav (/admin/settings/entry-type-policy). Non-admins need the Manage entry type rules permission (Settings → Users → User groups).
Admins who open Settings → Plugins → Entry Type Policy are redirected to the same page.
Alternatively, version-control rules in config/entry-type-policy.php — which overrides the control panel, as Craft's config files always do:
<?php return [ 'rules' => [ ['section' => 'pages', 'entryType' => 'servicesPage', 'userGroups' => ['superUsers']], ['section' => 'pages', 'entryType' => 'newsOverview', 'limit' => 1, 'limitPer' => 'site', 'userGroups' => ['superUsers']], ], ];
In the config file, sites and userGroups take an array of handles (or a comma-separated string). In the control panel, pick sites and groups from the checkbox lists; leave all unchecked for “all sites” / “anyone”.
While config/entry-type-policy.php defines rules, the settings page turns read-only and names the file, and saving is refused. Any rules previously stored in the control panel are left untouched, so deleting the config file brings them back.
Development
Rule matching lives in src/RuleSet.php and is deliberately free of Craft, so it can be checked without booting anything:
composer test # php tests/resolve-rule-test.php composer check-cs composer phpstan
Everything that does need Craft — user group matching, the admin bypass, limit counting, and the entry type filter — is covered by tests/integration-check.php, which runs against a real install:
ddev exec php plugins/craft-entry-type-policy/tests/integration-check.php
It reads only; it creates no content. The fixtures it expects are listed at the top of the file.
CI builds those fixtures on a throwaway Craft install with tests/seed-fixtures.php. That script does create sites, sections, groups, users and entries — never point it at a project whose content you care about.
License
MIT