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.
Craft CMS 5.8.0 or later, PHP 8.2 or later.
composer require zeix/craft-entry-type-policy
php craft plugin/install entry-type-policyA 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.
Three settings, in increasing order of fussiness.
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']],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.
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.
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.
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.
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.
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 phpstanEverything 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.phpIt 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.
MIT