How to Copy a Custom Certificate Design From One Moodle Course to Another
The Custom certificate plugin has no course-to-course copy button. This is the short script we use to clone a design exactly — with a rollback file, verification queries, and the two shortcuts to avoid.
One course's certificate carried an old, slightly broken design. The course next to it had the correct, current one. The job: make course A's certificate a pixel-exact copy of course B's, touching nothing else — same activity, same issued-certificate history, new design.
Moodle™ gives you no button for this. The Custom certificate plugin (mod_customcert) has a "Load template" option on the certificate editing screen, but it only offers site-level templates — the ones created under Site administration → Plugins → Custom certificate. A design that lives inside another course's activity simply isn't on the list. So people rebuild the design by hand, element by element, eyeballing x/y positions. We'd rather not.
The plugin's own API does this in one call. The whole fix is about twenty lines, and you can roll it back. Here's the complete procedure.
Root cause
Where Moodle actually stores a certificate design
Three tables, one pointer. Each activity row in mdl_customcert carries a templateid pointing at one row in mdl_customcert_templates. The template owns pages (mdl_customcert_pages), and each page owns elements (mdl_customcert_elements) — every text block, image, border, and signature line, with its posx, posy, font, fontsize, colour, width, and a data blob of element-specific settings.
The design is the pages plus the elements. Copying a design means replacing the target template's pages and elements with clones of the source's — while leaving the target's template row, activity row, and issue history alone. That's exactly what \mod_customcert\template::copy_to_template() does; the plugin itself uses it when you save an activity design as a site template.
What not to do
Two shortcuts that look right and aren't
Shortcut one: repoint the activity. UPDATE mdl_customcert SET templateid = 41 WHERE id = ... is a very tempting one-liner. Don't. Now two activities share one template row: edit the design in either course and both change, and deleting either activity can take the template down with it, leaving the survivor's certificate pointing at nothing. The customcert data model is built for a template per activity (site templates are the deliberate exception, parked in the system context) — know whether you're editing a shared template or a course-local one before you touch anything.
Shortcut two: backup and restore the activity. Duplicating the good activity into course A does carry the design across, but as a new activity — new course module id, empty issue history, and you still have to delete the old activity and re-wire any completion rules or access restrictions pointing at it. Fine for a brand-new course. Wrong when the target activity already has rows in mdl_customcert_issues that you'd like to keep.
We wanted the existing activity, with its history, wearing a new design. That means the API.
The fix
Copying the design with copy_to_template()
First, get your two template ids and confirm nothing shares them:
-- Find the template behind each certificate activity
SELECT id, course, name, templateid
FROM mdl_customcert
WHERE course IN (:sourcecourse, :targetcourse);
-- Safety check: no template should serve more than one activity
SELECT templateid, COUNT(*) AS activities
FROM mdl_customcert
GROUP BY templateid
HAVING COUNT(*) > 1;
If the second query returns your target template, stop — you're about to redesign more than one course's certificate. Otherwise, drop this in your Moodle dirroot and run it with php copy_cert_design.php:
<?php
define('CLI_SCRIPT', true);
require(__DIR__ . '/config.php');
$srcid = 41; // template id of the good design (course B)
$dstid = 57; // template id to overwrite (course A)
$srcRec = $DB->get_record('customcert_templates', ['id' => $srcid], '*', MUST_EXIST);
$dstRec = $DB->get_record('customcert_templates', ['id' => $dstid], '*', MUST_EXIST);
$src = new \mod_customcert\template($srcRec); // good design
$dst = new \mod_customcert\template($dstRec); // target
// Dump target pages/elements to JSON for rollback, then:
$backup = [];
foreach ($DB->get_records('customcert_pages', ['templateid' => $dst->get_id()]) as $p) {
$p->elements = array_values(
$DB->get_records('customcert_elements', ['pageid' => $p->id], 'sequence'));
$backup[] = $p;
}
file_put_contents("customcert-rollback-{$dstid}.json",
json_encode($backup, JSON_PRETTY_PRINT));
foreach ($DB->get_records('customcert_pages', ['templateid' => $dst->get_id()]) as $p) {
$dst->delete_page($p->id);
}
$src->copy_to_template($dst);
purge_all_caches();
Back up before you delete. The JSON dump is your design-level rollback, but it doesn't restore itself — take a real database backup too, and run the script on staging first. delete_page() removes the page and its elements permanently; the dump is the only record of the old design once it runs.
Why delete the target's pages at all? Because copy_to_template() adds the source's pages to whatever the target already has. Skip the delete and you get the new design stacked after the old one — a four-page certificate where two pages are the broken layout you were trying to get rid of.
Verification
How to prove the copy is exact
Don't eyeball it. Three checks, in order:
Counts match, source untouched. Page and element counts should now be identical for both templates, and the source's should be exactly what they were before the run:
SELECT t.id, t.name,
COUNT(DISTINCT p.id) AS pages,
COUNT(e.id) AS elements
FROM mdl_customcert_templates t
LEFT JOIN mdl_customcert_pages p ON p.templateid = t.id
LEFT JOIN mdl_customcert_elements e ON e.pageid = p.id
WHERE t.id IN (41, 57)
GROUP BY t.id, t.name;
Byte-compare the elements. Export both element sets ordered by page then sequence, and diff the columns that define the design: element, data, font, fontsize, colour, posx, posy, width, refpoint. Everything should be identical; only id, pageid, and the timestamps may differ. When we did this, every copied element matched its source byte for byte — that's the bar.
Render the PDF. Log in as a test learner in the target course and download the certificate. Cached template data is the usual reason a successful copy "doesn't show up," which is why purge_all_caches() is in the script and not a footnote.
Gotcha: already-issued certificates change too. customcert records issues in mdl_customcert_issues but renders the PDF from the current template on every download. A learner who re-downloads a certificate issued last year gets the new design. Usually that's the point — the old design was broken. If the historical look matters for audit reasons, archive PDFs of existing issues before you run the copy.
If you'd rather hand this off
Everything above is the complete procedure — script, safety checks, rollback. If your situation is messier — dozens of courses to restamp, a shared template already causing crosstalk, or a design that needs rebuilding rather than copying — this is routine work for our Moodle development service. Tell us what you're seeing and we'll reply within one business day with what it would take.
Quick answers
Questions people ask about this
Can I copy a certificate design between Moodle courses without writing code?
Only via detours. The certificate editing screen's Load template option only lists site-level templates created under Site administration, so a design living inside another course's activity is never offered there. Your no-code options are rebuilding the design by hand or restoring a backup of the whole activity, which creates a new activity and loses the target's issue history. The copy_to_template() API is the only way to restyle an existing activity in place.
Where does Moodle store custom certificate designs?
In three tables. Each activity row in mdl_customcert has a templateid pointing at one row in mdl_customcert_templates. The template owns pages in mdl_customcert_pages, and each page owns the design elements in mdl_customcert_elements, including position, font, colour, and an element-specific data blob.
Will copying a template change certificates that were already issued?
Yes, in effect. The plugin records issues in mdl_customcert_issues but renders the PDF from the current template every time it is downloaded, so a learner who re-downloads an old certificate gets the new design. If the historical appearance matters for audits, archive PDFs of existing issues before copying.
Why not just point the target activity at the source template with an SQL update?
Because then two activities share one template row. Editing the design in either course changes both, and deleting either activity can delete the template out from under the other. Copying the pages and elements keeps the two courses fully independent.
Do I need to purge caches after copying a certificate template?
Yes. Call purge_all_caches() in your script or run admin/cli/purge_caches.php afterward. Moodle caches template data, and a stale cache is the most common reason a successful copy appears not to have worked.