add_moduleinfo: "Column 'module' cannot be null" — and the errors it hides

Everything we learned building Moodle courses entirely from CLI scripts: the numeric id the docs never mention, the aborted-transaction trap that turns one mistake into a wall of generic database errors, and how to make page content and files actually persist.

add_moduleinfo() needs $moduleinfo->module — the numeric id from mdl_modules — not just modulename. The course form sets it; a CLI script must fetch it with $DB->get_field('modules', 'id', ...). And never catch-and-continue its exceptions: it runs in a delegated transaction, and once aborted, every later write fails with a generic "Error writing to database" that masks the real error.

You're building Moodle™ course modules from a script — a migration, a bulk import, a course generator — and add_moduleinfo() is fighting you. We've been there, at scale, and this post is the writeup we wish had existed: the three separate traps in this one function, why the error messages actively lie to you, and complete working code.

You probably got here by googling one of these:

  • "add_moduleinfo Column 'module' cannot be null"
  • "moodle create course modules programmatically cli"
  • "add_moduleinfo page content empty" or "resource file not attached cli"

And you're staring at one of these:

Undefined property: stdClass::$module in course/modlib.php
Error writing to database (Column 'module' cannot be null ...
  INSERT INTO mdl_course_modules (course,module,instance,...) VALUES(...))

Or — the worst one — no error at all. The script finishes green, the course renders, and every page is blank and every resource has no file attached.

All three symptoms come from the same small cluster of undocumented assumptions inside course/modlib.php. Let's take them in order.

Root cause

What "Column 'module' cannot be null" actually means

add_moduleinfo() expects $moduleinfo->module to already contain the numeric id of the module type — the id column from the mdl_modules table. Setting $moduleinfo->modulename = 'page' is not enough. The function never looks the id up for you, because in normal life it doesn't have to: the course-edit form resolves it and posts it along with everything else. A CLI caller has no form, so nothing fills it in, and the INSERT INTO mdl_course_modules lands with module = NULL.

That's the whole first bug. One field.

$moduleinfo (your script) modulename = 'page' module = ? ← missing mdl_modules id   name 15   page 17   quiz 18   resource INSERT INTO mdl_course_modules module = 15 ✓ $DB->get_field('modules', 'id', ['name'=>'page'], MUST_EXIST) The course-edit form does this lookup for you on every save. A CLI caller has no form — it must set the numeric id itself.
The one missing field. Ids in mdl_modules vary per site — always look them up by name, never hardcode them.

Why every write after the first failure also fails

This is the trap that costs people the afternoon. add_moduleinfo() runs inside a delegated transaction. If it throws and your loop does the "sensible" defensive thing —

try {
    add_moduleinfo($mi, $course);
} catch (Exception $e) {
    mtrace('skipping broken module: ' . $e->getMessage());
    continue;   // <-- this line ruins the rest of your run
}

— you now have an aborted transaction that nobody rolled back, and every subsequent database write in the process fails. Each one reports the same generic "Error writing to database". So your log shows forty identical failures, and the one line that names the real problem is the first one, buried at the top.

We learned this the slow way: we spent time debugging module #2's "database error" when module #2 was completely fine. The corpse of module #1's exception was poisoning it.

Your CLI loop What your log shows add_moduleinfo( module #1 ) throws inside a delegated transaction Column 'module' cannot be null the only real error in the run catch (Exception $e) { continue; } transaction is now aborted — nothing after this point can commit any write for module #2 Error writing to database (generic) any write for module #3 Error writing to database (generic) … every later write, forever Error writing to database (generic)
One swallowed exception, forty fake errors. Only the first message in the log is worth reading.

The third trap is quieter: editor and file fields — page content, resource files — don't reliably persist when you hand-build the structures without a real draft itemid. No exception, no warning. Just empty modules. We'll fix that below.

Diagnosis

How to confirm this on your own site

Three quick checks, in order:

1. Is $mi->module actually set? Dump the object immediately before the add_moduleinfo() call. If you only see modulename, that's your null column. You can also confirm what the id should be:

SELECT id, name FROM mdl_modules WHERE name = 'page';

2. Are you swallowing exceptions? Search your script for try anywhere around add_moduleinfo. If a catch block logs and continues, scroll your output to the first failure of the run — that's the real error. Ignore everything after it; those are transaction echoes, not independent bugs.

3. Did the files land? For a module that renders empty, check what's actually in the file storage for its context:

SELECT component, filearea, itemid, filepath, filename
FROM mdl_files
WHERE contextid = :contextid AND filename <> '.';

Files sitting in a user/draft area instead of mod_page/content (or mod_resource/content) means your draft-area handoff never happened.

The fix

The fields add_moduleinfo actually needs

Look the module id up by name, and set the generic fields the function assumes the form has provided. This is the block that ended our null-column errors for good:

require_once($CFG->dirroot . '/course/modlib.php');

$mi = new stdClass();
$mi->modulename = $modname;      // 'page', 'resource', 'forum', ...
$mi->course     = $course->id;
$mi->section    = $sectionnum;   // section NUMBER, not id
$mi->name       = $title;
$mi->visible    = 1;
$mi->introeditor = ['text' => '', 'format' => FORMAT_HTML, 'itemid' => 0];

// The one everyone misses — the numeric id, not the name:
$mi->module = $DB->get_field('modules', 'id', ['name'=>$modname], MUST_EXIST);

// also set the generic fields add_moduleinfo expects:
$mi->cmidnumber=''; $mi->groupmode=0; $mi->groupingid=0;
$mi->visibleoncoursepage=1; $mi->completion=0; $mi->completionexpected=0;
$mi->showdescription=0; $mi->availabilityconditionsjson=null;

$info = add_moduleinfo($mi, $course);

Do not wrap each add_moduleinfo() in a try/catch that continues. Let a failure stop the run. If you must batch, restart the whole script after fixing the cause — an aborted delegated transaction cascades through everything that follows, and a "resilient" loop just manufactures noise. Fail fast here is not a style preference; it's the only way the log stays honest.

Why your page content and files come out empty

Editor fields (introeditor, page content) and file fields normally receive a draft itemid — a pointer to files the browser uploaded into the user's draft area, which Moodle then moves into the module's own filearea on save. Hand-built structures with a made-up itemid don't error. The move just silently doesn't happen.

We tried faking draft itemids first. Don't. The modules save, the run is green, and the content is gone.

Two routes actually work. The by-the-book route is a real draft area: file_get_unused_draft_itemid() plus a real user context, populate it with $fs->create_file_from_pathname(), pass that itemid in the editor array. It works, but it drags a fake "uploading user" into a CLI process.

The route we now use in CLI scripts is more direct: create the module first, then place files straight into the module's own filearea and set the content column:

$fs  = get_file_storage();
$cm  = get_coursemodule_from_instance('page', $pageid);
$ctx = context_module::instance($cm->id);

$fs->create_file_from_pathname((object)[
    'contextid'=>$ctx->id, 'component'=>'mod_page', 'filearea'=>'content',
    'itemid'=>0, 'filepath'=>'/', 'filename'=>$name], $localpath);

// $html references the file with the placeholder Moodle rewrites at
// render time, e.g.  <img src="@@PLUGINFILE@@/diagram.png">
$DB->set_field('page', 'content', $html, ['id'=>$pageid]);

The @@PLUGINFILE@@ token is the important part: Moodle rewrites it to a real pluginfile.php URL when the page renders, so the same content works regardless of wwwroot. Store the literal token in the database, never a hardcoded URL.

What didn't work editor field with a made-up draft itemid draft → filearea move never runs, no error page saves — content and files empty ✗ What works in CLI add_moduleinfo() first, get the module context file into mod_page / content, itemid 0 @@PLUGINFILE@@ → pluginfile.php, 200 ✓ Create the module first; write files into its own filearea; store the @@PLUGINFILE@@ token, not a URL.
The silent failure mode and the route that avoids it. No exception separates the two — only the rendered result.

Verification

How to prove the built course actually works

Don't trust a green script. Verify three things on the rendered course:

  • Links resolve. Open a built page and confirm every @@PLUGINFILE@@ reference now points at a pluginfile.php URL that returns 200 with the right content-type. A 200 that serves an HTML error page instead of your PDF still looks like a working link in the browser.
  • Modules appear on the course page. If an instance row exists but the module is invisible, something bypassed add_moduleinfo() — see the gotchas below.
  • Exactly one Announcements forum. create_course() auto-creates one. If your script adds its own, every course ends up with two:
SELECT COUNT(*) FROM mdl_forum WHERE course = :courseid AND type = 'news';

That should return 1. If your import source lists a news forum among its modules, skip it.

Gotchas that survive the first fix

  • Don't insert into mdl_course_modules directly. It's tempting once you've seen the INSERT in the error message. But add_moduleinfo() also creates the activity instance, stitches the module into the section sequence, fires events, and invalidates the course cache. A raw insert gives you a module the database knows about and the course page doesn't.
  • Section number, not section id. $mi->section is the ordinal (0, 1, 2…), not the mdl_course_sections.id. Passing the id "works" until it lands your module in section 4,913.
  • Module ids differ between sites. mdl_modules.id depends on install order and which plugins exist. The get_field lookup by name is mandatory; a hardcoded id from your dev box will build quizzes where you meant pages.
  • Purge caches if you fiddled at the SQL level. After any manual repair, php admin/cli/purge_caches.php — the course modinfo cache will otherwise keep showing you the broken past.

When to script it and when to call someone

Everything above is enough to build courses in code reliably — that's why we wrote it down. The pattern (numeric module id, fail fast, files straight into the module filearea, @@PLUGINFILE@@ in content) has held up for us across thousands of generated modules. But if you're staring at a bulk import that has to land clean — hundreds of courses, mixed module types, source content in odd formats — that's the kind of Moodle development work we do every week, and course-generation scripts are among our favorite jobs precisely because the traps are so well mapped. Tell us what you're importing and we'll reply within one business day with an honest read on whether you need us at all.

Quick answers

Questions people ask about this

What does "Column 'module' cannot be null" mean in Moodle's add_moduleinfo?

It means $moduleinfo->module was never set. That field must contain the numeric id of the module type from the mdl_modules table, not the name string. The course-edit form looks it up automatically on every save, but a CLI script must fetch it itself with $DB->get_field('modules', 'id', ['name' => $modname], MUST_EXIST).

Why do all my database writes fail with "Error writing to database" after one module fails?

add_moduleinfo runs inside a delegated transaction. If your code catches its exception and continues, the transaction stays aborted, and every later write in the process fails with the same generic message. Only the first error in the log is real; the rest are echoes. Let the failure stop the run instead of catching and continuing.

Why is my programmatically created Page or File resource empty?

Editor and file fields expect a real draft itemid pointing at files in a user's draft area, which Moodle moves into the module filearea on save. A hand-built structure with a made-up itemid fails silently — no error, just empty content. Either use file_get_unused_draft_itemid with a real user context, or create the module first and write files directly into its own filearea with create_file_from_pathname.

Can I insert into mdl_course_modules directly instead of calling add_moduleinfo?

No. add_moduleinfo also creates the activity instance row, adds the module to the section sequence, fires events, and invalidates the course cache. A raw insert produces a module the database contains but the course page never shows. Use the API and give it the fields it expects.

Why does my scripted course end up with two Announcements forums?

create_course automatically adds a news forum to every new course. If your import source also lists one and your script creates it, you get a duplicate. Skip news-type forums in your import loop, and verify afterward that the count of type 'news' forums per course is exactly one.