To add a capability in Moodle, define it in your plugin's db/access.php file as an entry in the $capabilities array, give it a language string in the plugin's lang/en file, bump the plugin version in version.php and run the upgrade. Moodle then writes the capability to the capabilities table and grants it to roles based on the archetypes or clonepermissionsfrom keys you set. Your code checks it with has_capability() or require_capability() against the right context.
That is the "moodle add capability access.php" workflow in one paragraph. The detail that catches most developers is what happens on sites that already have the plugin installed: Moodle updates a capability's type, risk and context level on upgrade, but it does not re-apply your default role permissions. This article walks through the file format, the upgrade behaviour, how to change or retire a capability safely, and how to check the result.
Checked against the Moodle 5.3.0 source (public/lib/accesslib.php and public/lib/upgradelib.php) and the Moodle developer documentation for the Access API and common plugin files, October 2026. In Moodle 5.1 and later the web root is public/; on 5.0 and earlier, drop that prefix from the paths below.
What a capability is and where Moodle stores it
Moodle uses role-based access control. A role is a set of permissions on capabilities, defined at system level and overridable lower down the context tree (category, course, activity, block, user). A capability is a named permission such as mod/folder:managefiles. Your code never asks "is this user a teacher?"; it asks "does this user have this capability in this context?".
The definitions you write in db/access.php end up in two tables:
capabilities: one row per capability withname,captype,contextlevel,componentandriskbitmask.role_capabilities: the permission (allow, prevent, prohibit) each role has on each capability, per context. Your archetype defaults are written here at system context when the capability is first created.
Moodle also caches the capability list in MUC (the core/capabilities cache), and update_capabilities() flushes it during upgrade, so you rarely need to purge caches by hand after a definition change.
Step 1: write db/access.php
The file returns nothing; it just sets $capabilities. Capability names follow plugintype/pluginname:capabilityname. update_capabilities() checks names against the pattern ^[a-z]+/[a-z_0-9]+:[a-z_0-9]+$ and emits a debugging message if a name does not match, so stick to lowercase letters, digits and underscores.
Here is a definition for a hypothetical local plugin, local_coursereports, that adds a read capability for viewing a course report and a write capability for exporting it. Not run on a live site; test on staging first.
<?php
// public/local/coursereports/db/access.php
defined('MOODLE_INTERNAL') || die();
$capabilities = [
'local/coursereports:view' => [
'captype' => 'read',
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'teacher' => CAP_ALLOW,
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
],
'local/coursereports:export' => [
'riskbitmask' => RISK_PERSONAL,
'captype' => 'write',
'contextlevel' => CONTEXT_COURSE,
'archetypes' => [
'editingteacher' => CAP_ALLOW,
'manager' => CAP_ALLOW,
],
'clonepermissionsfrom' => 'moodle/grade:export',
],
];
The developer docs add one rule for every file in db/: no require_once or other file inclusion. These files are parsed in performance-sensitive places (for example, get_default_capabilities() and get_deprecated_capability_info() require every component's access.php), so keep them as plain definitions with no side effects.
The keys, one by one
| Key | Required | What it does |
|---|---|---|
captype | Yes | read or write. has_capability() always returns false for the guest account and not-logged-in users on any write capability, whatever the role settings say. |
contextlevel | Yes | The typical level where the capability is checked, such as CONTEXT_SYSTEM, CONTEXT_COURSECAT, CONTEXT_COURSE, CONTEXT_MODULE, CONTEXT_BLOCK or CONTEXT_USER. It can still be checked in lower contexts. It also decides where the capability appears on the role override screens. |
riskbitmask | No (defaults to 0) | Combine RISK_MANAGETRUST, RISK_CONFIG, RISK_XSS, RISK_PERSONAL, RISK_SPAM and RISK_DATALOSS with |. Risks show as icons in the role editor. Guests and not-logged-in users are also denied any capability carrying RISK_XSS, RISK_CONFIG or RISK_DATALOSS. |
archetypes | No | Default permissions per role archetype: manager, coursecreator, editingteacher, teacher, student, guest, user, frontpage. The docs recommend only CAP_ALLOW here. An unknown archetype name throws an exception during upgrade. |
clonepermissionsfrom | No | Copies every role's existing permission on another capability, in every context, when the new capability is created. If set and the source capability exists, archetypes is ignored for that install. |
Two choices deserve thought. First, set riskbitmask honestly: RISK_PERSONAL for anything that exposes user data, RISK_XSS where users can submit content that is rendered unfiltered, RISK_CONFIG for site configuration. is_safe_capability() uses these bits to decide whether holders of moodle/role:safeoverride may override the capability. Second, prefer clonepermissionsfrom when an existing capability already expresses "who should be able to do this". Sites with heavily customised roles get better defaults from a clone than from archetypes, which is why core activity modules clone addinstance from moodle/course:manageactivities.
Step 2: add the language strings
Moodle shows capability names through get_capability_string(). For local/coursereports:view it looks in component local_coursereports for the string coursereports:view:
// public/local/coursereports/lang/en/local_coursereports.php
$string['pluginname'] = 'Course reports';
$string['coursereports:view'] = 'View course reports';
$string['coursereports:export'] = 'Export course reports';
Without the string, the role screens show a missing-string marker and developer debugging complains. The string key drops the plugin type prefix; only pluginname:capname is used.
Step 3: bump the version and upgrade
Capabilities are synchronised by update_capabilities($component), which upgrade_component_updated() calls after a plugin is installed or upgraded. Nothing reads access.php on a normal page load, so you must increase $plugin->version in version.php after every change to the file, then run the upgrade from Site administration or the CLI:
php admin/cli/upgrade.php --non-interactive
On Moodle 5.1 and later, admin/cli sits at the project root, outside public/. The developer docs also mention lowering the stored version number in the database to re-run an upgrade during development; do that only on a throwaway dev site.
Step 4: check the capability in your code
Check at the most specific context that makes sense. For a course-level page:
<?php
// public/local/coursereports/index.php
require(__DIR__ . '/../../config.php');
$courseid = required_param('id', PARAM_INT);
$course = get_course($courseid);
require_login($course);
$context = \core\context\course::instance($course->id);
require_capability('local/coursereports:view', $context);
$PAGE->set_url(new moodle_url('/local/coursereports/index.php', ['id' => $course->id]));
$PAGE->set_context($context);
$PAGE->set_title(get_string('pluginname', 'local_coursereports'));
echo $OUTPUT->header();
if (has_capability('local/coursereports:export', $context)) {
// Show the export button. The export script must check the
// capability again and confirm sesskey before doing anything.
}
echo $OUTPUT->footer();
Not run on a live site; test on staging first. require_capability() throws \core\exception\required_capability_exception when the check fails; has_capability() returns a boolean for conditional UI. Both return true for site administrators by default, because the fourth parameter $doanything defaults to true. The developer docs advise against passing false. The older context_course class name still works in 5.3 as an alias of \core\context\course.
Hiding a button is never the security check. Every script that performs the action must call require_capability() itself, and state-changing requests must also check require_sesskey(). The same applies to external functions: validate the context and check the capability inside execute().
What happens on sites that already have the plugin
This is where the official guidance "changes only take effect for new installations" needs unpacking. Reading update_capabilities() in 5.3.0, an upgrade does the following:
| Change in access.php | Effect on an existing site after upgrade |
|---|---|
| New capability added | Inserted into capabilities; permissions copied from clonepermissionsfrom, or else archetype defaults assigned at system context. |
captype, riskbitmask or contextlevel changed | Updated in the capabilities row. |
archetypes or clonepermissionsfrom changed on an existing capability | Ignored. Existing role permissions stay as they are. |
| Capability removed from the array | Deleted from capabilities, unassigned from roles and deleted from role_capabilities in every context, overrides included. |
| Capability renamed | Treated as a removal plus a new capability. Every override on the old name is lost unless the new one clones from it. |
So "new installs only" applies to the permission defaults. The developer docs say updating an installed site's capability configuration is not recommended, because an administrator may already have changed it. If you genuinely need a different default, add a new capability, or document the change in your release notes and let administrators apply it.
Administrators can pick up new defaults by editing a role under Site administration > Users > Permissions > Define roles and using Reset. Be careful: reset_role_capabilities() deletes all of that role's system-level permissions and rebuilds them from every component's archetype defaults, not just your plugin's. Any site-wide customisation of that role is lost.
Renaming or retiring a capability
Because removal deletes overrides, never simply rename a key. Moodle gives you two tools.
Clone into the new name
Add the new capability with 'clonepermissionsfrom' => 'local/coursereports:oldname' and remove the old one in the same release. update_capabilities() creates and clones new capabilities before it runs capabilities_cleanup(), so the old permissions are copied across, overrides included, before the old capability is deleted. The debugging message for invalid capability names points to this same field for migrations.
Deprecate with $deprecatedcapabilities
For capabilities that third-party code might still check, follow the deprecation process. Move the entry out of $capabilities and into a $deprecatedcapabilities array in the same file:
$deprecatedcapabilities = [
'local/coursereports:oldname' => [
'replacement' => 'local/coursereports:view',
'message' => 'Use local/coursereports:view instead.',
],
];
Checks against the deprecated name are redirected to the replacement, and Moodle outputs a debugging message at DEBUG_DEVELOPER level. get_deprecated_capability_info() reads these arrays and caches them in the capabilities cache. Combine this with clonepermissionsfrom on the replacement if it is a new capability, so existing permissions carry over.
Plugin-type conventions and common mistakes
- Activity modules need
mod/[modname]:addinstance(who may add the activity) and usuallymod/[modname]:view, as described in the activity module docs. - Blocks need
block/[name]:addinstanceand, if the block can go on the Dashboard,block/[name]:myaddinstance. Core blocks clone these frommoodle/site:manageblocksandmoodle/my:manageblocks. - Wrong context level. A capability defined at
CONTEXT_SYSTEMbut checked in a course will work, but course-level overrides will not show on the course permission screen, which confuses administrators. - Granting to
userorguestarchetypes by habit. Theuserarchetype is every authenticated user on the site. Grant only what a role needs. - Checking a role name instead of a capability. The Access API docs are explicit: ask whether the user has a capability, not whether they hold a role. Avoid
is_siteadmin()checks in activity code for the same reason. - Forgetting the version bump. The file changes, nothing happens, and the capability is missing from the role editor.
Verify the result
- Go to Site administration > Users > Permissions > Capability overview, pick your capability and confirm the permission each role has.
- Use Check system permissions, or Check permissions in a course, for a test user. Its "Why does ... have capability ..." view shows which role and context grant or block the capability.
- Log in as a student or guest test account and request the protected URL directly. You should get the "Sorry, but you do not currently have permissions to do that" error, naming the capability, not the report.
- Write a PHPUnit test that assigns a role in a course context and asserts
has_capability()returns what you expect.
For the surrounding plugin structure (version.php, settings, privacy provider), see our Moodle local plugin tutorial, and for which plugin type to choose in the first place, Moodle plugin types explained. The Moodle guide covers the wider platform.
How we can help
If you are designing a permission model for a custom Moodle plugin, or untangling role overrides after a capability was renamed, our Moodle plugin development team can review your access.php, upgrade path and tests. For broader Moodle work, see our Moodle services.
Frequently asked questions
Why doesn't my new Moodle capability appear in the role editor?
Moodle only reads db/access.php during plugin install or upgrade. Increase $plugin->version in version.php and run the upgrade, from Site administration or with php admin/cli/upgrade.php. Also check that the capability name matches plugintype/pluginname:capabilityname in lowercase.
I changed the archetypes in access.php. Why didn't existing roles change?
Archetype and clonepermissionsfrom defaults are applied only when a capability is created on a site. Upgrades update captype, riskbitmask and contextlevel, but leave role permissions alone because an administrator may have changed them. Administrators can apply new defaults with Reset on the Define roles screen, which rebuilds all of that role's system-level permissions.
How do I rename a Moodle capability without losing overrides?
Add the new capability with clonepermissionsfrom set to the old name and remove the old one in the same release. Moodle creates and clones new capabilities before it deletes removed ones. If other code might still check the old name, list it in $deprecatedcapabilities with a replacement so checks are redirected.
What is the difference between has_capability() and require_capability()?
has_capability() returns true or false, which suits conditional output. require_capability() throws a required capability exception when the check fails, which suits the top of a script that must not run for unauthorised users. Both return true for site administrators by default.
Free 30-minute consultation
Not sure where to start? Talk to an LMS engineer.
Tell us what platform you run and what you need. You’ll get honest, practical advice - even if the answer is that you don’t need us.
Sources & references
- Access API - Moodle Developer Resources
- Common files: db/access.php - Moodle Developer Resources
- accesslib.php (v5.3.0) - Moodle HQ on GitHub
- mod_folder db/access.php (v5.3.0) - Moodle HQ on GitHub
Learn more about this topic with AI
Gemini opens with the prompt copied - paste it into the chat to start.