From moodle-dev
Optimizes Moodle plugin performance with MUC caching, query optimization, recordsets, ad-hoc tasks, lazy loading, OPcache, and performance debug toolbar.
How this skill is triggered — by the user, by Claude, or both
Slash command
/moodle-dev:moodle-performanceThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Moodle bottlenecks: too many DB queries per request, full-table scans, loading large recordsets into memory, blocking work on the request path. Mitigations: MUC caching, indexes, recordsets, ad-hoc tasks, careful capability checks.
Moodle bottlenecks: too many DB queries per request, full-table scans, loading large recordsets into memory, blocking work on the request path. Mitigations: MUC caching, indexes, recordsets, ad-hoc tasks, careful capability checks.
config.php:
$CFG->debug = (E_ALL | E_STRICT);
$CFG->debugdisplay = 1;
$CFG->perfdebug = 15; // shows query count + timings in footer
Site admin > Development > Debugging > Performance info: enables the footer toolbar (queries, time, memory, MUC hits/misses, sessions reads/writes).
$PERFglobal $PERF;
$start = microtime(true);
// ... work ...
$PERF->dbqueries++; // your manual counter
mtrace('Took ' . round(microtime(true) - $start, 3) . 's');
Postgres / MySQL slow-query log — turn on in dev. Moodle prefixes queries with the source class via $CFG->dboptions['debug'].
3 cache modes:
| Mode | Storage | Lifetime | Use |
|---|---|---|---|
MODE_APPLICATION | Persistent (file/Redis/Memcached) | Until invalidated | Shared computed values |
MODE_SESSION | Session | Session lifetime | Per-user computed values |
MODE_REQUEST | PHP request | Single request | Memoize within request |
db/caches.php:
<?php
defined('MOODLE_INTERNAL') || die();
$definitions = [
'active_announcements' => [
'mode' => cache_store::MODE_APPLICATION,
'simplekeys' => true,
'simpledata' => true,
'ttl' => 600,
'invalidationevents' => ['changesin_local_announcements'],
],
'user_dashboard' => [
'mode' => cache_store::MODE_SESSION,
'simplekeys' => true,
],
];
Bump version.php after edits.
$cache = \cache::make('local_announcements', 'active_announcements');
$value = $cache->get('all');
if ($value === false) {
$value = $this->compute_announcements();
$cache->set('all', $value);
}
return $value;
\cache_helper::invalidate_by_event('changesin_local_announcements', ['all']);
// or
$cache->delete('all');
$cache->purge();
Trigger invalidation from a settings save or an observer.
simplekeys / simpledatasimplekeys: true — keys are alphanum (skips hashing) — fastersimpledata: true — values are scalars/arrays of scalars (skips serialization) — much fasterUse both whenever possible.
$cache = \cache::make('local_announcements', 'request_lookup');
// MODE_REQUEST in caches.php — auto-cleared at end of request
// BAD — loads everything into memory
$rows = $DB->get_records('huge_table');
foreach ($rows as $r) { /* ... */ }
// GOOD — streams
$rs = $DB->get_recordset('huge_table');
foreach ($rs as $r) { /* ... */ }
$rs->close(); // ALWAYS close
Use get_recordset_sql with LIMIT + offset for batched processing of millions of rows.
// BAD
foreach ($courses as $c) {
$teacher = $DB->get_record('user', ['id' => $c->teacherid]);
}
// GOOD — single query
$tids = array_column($courses, 'teacherid');
[$insql, $params] = $DB->get_in_or_equal($tids);
$teachers = $DB->get_records_sql("SELECT * FROM {user} WHERE id $insql", $params);
<INDEX NAME="userid-courseid" UNIQUE="false" FIELDS="userid, courseid"/>
Edit via XMLDB editor. Composite index column order matters — most-selective first, leftmost-prefix usable for partial queries.
EXPLAIN your queriesmysql> EXPLAIN SELECT ... ;
postgres=# EXPLAIN ANALYZE SELECT ... ;
Look for:
type: ALL (full scan) — needs indexUsing filesort / Using temporary — sort spillingrows estimate — selectivity issue// classes/task/send_report.php
namespace local_example\task;
class send_report extends \core\task\adhoc_task {
public function execute(): void {
$data = $this->get_custom_data();
// do work
}
}
// trigger:
$task = new \local_example\task\send_report();
$task->set_custom_data(['userid' => $user->id, 'reportid' => $r->id]);
$task->set_userid($user->id); // runs as that user
\core\task\manager::queue_adhoc_task($task);
Tasks run via cron. Long jobs don't block HTTP request.
db/tasks.php:
$tasks = [[
'classname' => 'local_example\task\cleanup',
'blocking' => 0,
'minute' => '0',
'hour' => '3',
'day' => '*',
'month' => '*',
'dayofweek' => '*',
]];
class cleanup extends \core\task\scheduled_task {
public function get_name(): string {
return get_string('task:cleanup', 'local_example');
}
public function execute(): void {
// ...
}
}
Run cron: php admin/cli/cron.php. Production: cron entry every minute.
$locktype = 'local_example_cleanup';
$lockfactory = \core\lock\lock_config::get_lock_factory($locktype);
if (!$lock = $lockfactory->get_lock('main', 5)) {
return; // another instance running
}
try {
// ...
} finally {
$lock->release();
}
has_capability() is expensive at scale. For lists:
// BAD — N capability checks
foreach ($users as $u) {
if (has_capability('mod/quiz:attempt', $context, $u)) { /* ... */ }
}
// GOOD — single batched query
$users = get_users_by_capability($context, 'mod/quiz:attempt', 'u.id, u.firstname, u.lastname');
$PAGE->set_pagelayout('embedded'); // skips heavy regions when appropriate
$PAGE->add_body_class('skip-some-blocks');
Lazy-load AMD modules:
// only load when needed
const onClick = async () => {
const {init} = await import('local_example/heavy');
init();
};
php.ini:
opcache.enable = 1
opcache.memory_consumption = 256
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0 # production only
opcache.revalidate_freq = 0
Moodle benefits massively from OPcache. With validate_timestamps=0, restart PHP after deploys.
APCu for Moodle config cache: $CFG->localcachedir = '/var/cache/moodle';
$CFG->session_handler_class = '\core\session\redis')// release session lock early when only reading
\core\session\manager::write_close();
npx grunt amd — production builds are minified$CFG->cachejs = true; $CFG->cachetemplates = true;$CFG->themedesignermode) — OFF in production (disables CSS cache)| Mistake | Fix |
|---|---|
get_records on huge table | get_recordset + close |
| N+1 in loops | Batch with get_in_or_equal |
| Sending email on POST | Move to ad-hoc task |
has_capability per row | get_users_by_capability |
MUC without simplekeys/simpledata | Set both true when possible |
MUC MODE_REQUEST for cross-request data | Use MODE_APPLICATION |
| Long lock without try/finally | Release in finally block always |
| Theme designer mode on prod | Off — kills CSS caching |
validate_timestamps = 1 | OFF in prod, restart on deploy |
Forgetting $rs->close() | Recordset leaks — always close |
| File sessions on multi-app-server | Switch to Redis/Memcached |
xhprof / tideways:
// quick on-demand profile
$CFG->profilingenabled = true;
$CFG->profilingautostart = false;
// add ?PROFILEME to URL — view in admin/tool/profiling
Per-request: Site admin > Development > Profiling.
npx claudepluginhub saadrahman01/claude-moodle-dev --plugin moodle-devOptimizes WordPress performance: profiles slow queries, N+1 problems, autoload bloat, object cache, Core Web Vitals, and bundling. Use before blind optimization.
Identifies and resolves performance bottlenecks through profiling, measurement, and targeted optimization across frontend, backend, and databases.
Guides performance optimization via profiling (CPU, memory, I/O), caching (CDN/app/DB), connection pooling, lazy loading, code splitting, query tuning, and load balancing. Use when diagnosing issues, cutting latency, or scaling.