luaguides

Designing Plugin Architectures in Lua

A plugin architecture lets other developers extend your application without touching your core code. Designing plugin architectures in Lua starts with the module system, which makes this straightforward — a plugin is just a table returned by a module, and your host application calls into it.

Plugin as Module

The simplest plugin is a Lua file that returns a table of functions:

-- plugins/my_plugin.lua
local M = {}

function M.on_load(app)
  print("plugin loaded:", app.name)
end

function M.on_enable(app)
  print("plugin enabled")
end

return M

The require function locates the module file by searching the directories listed in Lua’s package.path. When require returns the table, your host application gains a handle to the plugin’s public API — every function keyed in that table is callable, while any local variable inside the module file stays private. This table-returning convention is the standard Lua module pattern, giving you clean separation between the plugin’s internal implementation and its published interface. Passing an application context table to lifecycle methods keeps the plugin loosely coupled to the host rather than reaching into global state.

local plugin = require("plugins.my_plugin")
plugin.on_load({ name = "MyApp" })

The plugin returns a table. The host decides when and how to call it.

Hook System

Most plugin systems work around hooks: named callbacks that the host calls at specific points. Define your hook registry:

A hook registry centralizes all extension points so plugins never need to know about each other or modify the host’s source code. When your host application fires a hook — after a player logs in, before a file is saved, on a timer tick — every registered callback for that hook name executes in priority order. This observer-style pattern is what makes plugin architectures truly extensible: you add behaviour by subscribing to events rather than by patching the host. The registry table uses string keys for hook names and stores an array of callback tables, each carrying the function reference and a numeric priority field.

local Hooks = {}

function Hooks.register(name, callback, priority)
  if not Hooks[name] then
    Hooks[name] = {}
  end
  table.insert(Hooks[name], {
    callback = callback,
    priority = priority or 100,
  })
  table.sort(Hooks[name], function(a, b)
    return a.priority < b.priority
  end)
end

function Hooks.invoke(name, ...)
  if not Hooks[name] then return end
  for _, hook in ipairs(Hooks[name]) do
    hook.callback(...)
  end
end

Lower priority numbers run first. Plugins that need to run before others set a lower number.

The table.sort call inside Hooks.register reorders the callback array every time a new hook is added, using a custom comparator that compares the priority fields. This means the execution sequence is deterministic regardless of registration order — a plugin registered last with priority 10 still runs before one registered first with priority 100. In practice, you might reserve priorities 1 through 50 for core system hooks like authentication and logging, while letting third-party plugins use 100 and above. If two plugins share the same priority, Lua’s default sort is not guaranteed stable across versions, so consider including a secondary sort key like registration timestamp for tiebreaking.

Register a hook from a plugin:

local M = {}

function M.on_load(app)
  app.hooks.register("player_damaged", function(player, amount)
    print(player.name, "took", amount, "damage")
  end, 50)
end

return M

Invoke hooks from the host:

The host application owns the hook invocation — it decides the exact moment and order in which hooks fire. Calling hooks.invoke passes along any arguments the hook callbacks expect, so a gameplay hook like player_damaged can forward the affected player table and the damage value directly. Each callback receives the same arguments in the same order, which means plugin authors must agree on a parameter convention. Documenting the expected signature for every hook name is essential — without it, plugin authors cannot write correct callbacks and debugging turns into guesswork.

function Game:damage_player(player, amount)
  player.health = player.health - amount
  self.app.hooks.invoke("player_damaged", player, amount)
end

Lifecycle Hooks

Plugins need to know when they’re being loaded, enabled, disabled, or unloaded. Define standard lifecycle hooks:

Lifecycle hooks give plugins predictable insertion points for setup and teardown. The load phase runs once when the module file is first required, letting the plugin register its event hooks and read configuration. The enable and disable phases let plugins toggle behaviour without reloading — useful for a plugin admin panel where users turn features on and off at runtime. The unload phase handles cleanup: closing file handles, cancelling timers, and removing references so the garbage collector can reclaim the plugin’s memory. Notice that disable_plugin is called automatically inside unload_plugin, so plugins never need to handle both teardown paths separately.

local PluginHost = {
  plugins = {},
  hooks = Hooks,
}

function PluginHost.load_plugin(name, path)
  local plugin_path = path .. "/" .. name .. ".lua"
  local plugin = require(path .. "." .. name)

  plugin._name = name
  plugin._enabled = false

  self.plugins[name] = plugin

  if plugin.on_load then
    plugin:on_load(self)
  end

  return plugin
end

function PluginHost.enable_plugin(name)
  local plugin = self.plugins[name]
  if not plugin then return end
  if plugin._enabled then return end

  if plugin.on_enable then
    plugin:on_enable(self)
  end
  plugin._enabled = true
end

function PluginHost.disable_plugin(name)
  local plugin = self.plugins[name]
  if not plugin or not plugin._enabled then return end

  if plugin.on_disable then
    plugin:on_disable(self)
  end
  plugin._enabled = false
end

function PluginHost.unload_plugin(name)
  local plugin = self.plugins[name]
  if not plugin then return end

  self:disable_plugin(name)

  if plugin.on_unload then
    plugin:on_unload(self)
  end

  self.plugins[name] = nil
end

Not every plugin needs every lifecycle hook. Check if the method exists before calling it.

Plugin Registry

Track what’s loaded and enabled:

A central registry decouples plugin identity from plugin loading. Instead of reaching into PluginHost.plugins by name, other subsystems query the registry to discover which plugins are active. This matters when you want to let plugins depend on other plugins: a chat moderation plugin can check Registry.is_enabled("chat_logger") and adjust its logging strategy if the logger is missing. The registry also makes it straightforward to build a /plugins admin page that lists enabled plugins and their status, since the lookup methods return plain Lua tables suitable for iteration with pairs.

local Registry = {
  loaded = {},
  enabled = {},
}

function Registry.register(plugin)
  self.loaded[plugin._name] = plugin
end

function Registry.enable(name)
  if self.enabled[name] then return end
  self.enabled[name] = true
end

function Registry.disable(name)
  self.enabled[name] = nil
end

function Registry.is_enabled(name)
  return self.enabled[name] ~= nil
end

function Registry.list_enabled()
  local list = {}
  for name, _ in pairs(self.enabled) do
    table.insert(list, name)
  end
  return list
end

Sandboxed plugin loading

Plugins you didn’t write shouldn’t have access to everything. Restrict the environment:

Setting a custom environment table via loadfile’s third argument creates a restricted namespace for untrusted plugin code. The plugin can still access standard Lua functions like pairs and table.insert, but dangerous modules like os.execute and io.open are either omitted entirely or replaced with safe wrappers. The setmetatable call with __index = _G acts as a fallback: if the sandboxed code references something not in the environment table, Lua searches _G next. This means you must explicitly omit functions you want to block — including them in the sandbox table with a nil value does not prevent the __index fallback from finding them in _G. For tighter sandboxing, set __index to an empty table or a whitelist instead of the global table.

function PluginHost.load_plugin_sandboxed(name, path)
  local plugin_path = path .. "/" .. name .. ".lua"

  local env = {
    print = print,
    pairs = pairs,
    ipairs = ipairs,
    table = table,
    string = string,
    math = math,
    type = type,
    pcall = pcall,
    error = error,
    -- restrict os and io
    os = {
      clock = os.clock,
      time = os.time,
      date = os.date,
    },
  }
  setmetatable(env, { __index = _G })

  local chunk, err = loadfile(plugin_path, nil, env)
  if not chunk then
    return nil, "failed to load: " .. err
  end

  local ok, plugin = pcall(chunk)
  if not ok then
    return nil, "plugin error: " .. tostring(plugin)
  end

  return plugin
end

The plugin can’t reach io.open, os.execute, or debug unless you explicitly expose them. Keep your host’s internals private by not putting them in the sandbox’s environment.

The sandbox approach trades convenience for safety. A plugin running in a restricted environment cannot call dofile to load arbitrary Lua files or use loadstring to execute dynamically generated code, but it also loses access to functions you might want to provide, such as coroutine.create for cooperative multitasking or require for loading plugin sub-modules. The right balance depends on your threat model: a plugin marketplace open to third-party developers needs stricter sandboxing than an internal plugin system where all authors work on the same team.

Plugin Configuration

Plugins often need configuration. Let them declare what they need:

-- plugins/analytics_plugin.lua
local M = {}

M.config_schema = {
  api_key = { type = "string", required = true },
  endpoint = { type = "string", default = "https://api.example.com" },
  batch_size = { type = "number", default = 10 },
}

function M.on_load(app)
  local config = app.config:get_plugin_config("analytics", M.config_schema)
  app.hooks.register("event", function(event)
    -- send to analytics
  end)
end

return M

The host validates and provides config. The plugin declares what it needs.

Auto-Discovery

Load all plugins from a directory automatically:

Auto-discovery removes the chore of manually registering every plugin in a master list. Your application scans a directory for .lua files, requires each one, and registers the returned table as a plugin. This is how most Lua plugin ecosystems work: LÖVE2D mods live in a mods/ folder, Lapis applications scan applications/, and World of Warcraft addons load from Interface/AddOns/. The io.popen approach shown here is simple and works on POSIX systems, but for cross-platform code you should use the LuaFileSystem library (lfs) or Penlight’s pl.dir module, which avoid spawning a subprocess.

function PluginHost.discover_plugins(plugins_dir)
  local handle = io.popen("ls " .. plugins_dir .. "/*.lua")
  if not handle then return end

  for filename in handle:lines() do
    local name = filename:match("([^/]+)%.lua$")
    if name and name ~= "init" then
      local ok, err = pcall(function()
        self:load_plugin(name, plugins_dir)
      end)
      if not ok then
        print("failed to load plugin", name, err)
      end
    end
  end

  handle:close()
end

In production, use lfs.attributes or pl.dir.dirlist instead of spawning ls. On OpenResty, ngx.location.capture with a virtual path works.

One subtlety with directory-based auto-discovery is load ordering. If plugin A depends on plugin B, but the filesystem scan encounters A first, A’s on_load will run before B is available. You can handle this by splitting loading into two phases — first require all modules without calling lifecycle hooks, then walk the loaded plugins and invoke their on_load methods in dependency order. Alternatively, have plugins declare dependencies in their returned table and topologically sort before initialisation.

Cross-Plugin Communication

Plugins shouldn’t call each other directly; that creates tight coupling. Instead, use an event bus mediator:

local EventBus = {
  listeners = {},
}

function EventBus.subscribe(event, callback)
  if not self.listeners[event] then
    self.listeners[event] = {}
  end
  table.insert(self.listeners[event], callback)
end

function EventBus.publish(event, ...)
  if not self.listeners[event] then return end
  for _, cb in ipairs(self.listeners[event]) do
    cb(...)
  end
end

-- In a plugin:
EventBus.subscribe("player_level_up", function(player, new_level)
  -- grant achievements, notify friends, etc.
end)

Plugins communicate through events, not direct calls. The host publishes; plugins subscribe.

An event bus decouples producers from consumers so neither needs a direct reference to the other. When a plugin publishes an event like player_level_up, it does not know or care which other plugins are listening — the bus handles delivery. This is critical for plugin ecosystems where plugins are developed independently: a quest-tracking plugin and an achievement plugin can both react to the same event without either author coordinating with the other. The bus itself is a simple table mapping event names to arrays of callback functions, making it lightweight enough to use in game loops that fire hundreds of events per frame.

Priority and Ordering

Some plugins need to run before others. Numeric priority handles this:

Hooks.register("init", function()
  print("this runs first")
end, 1)

Hooks.register("init", function()
  print("this runs second")
end, 10)

Lower numbers execute first. Reserve low numbers (1-99) for core plugins and use higher numbers for optional ones.

Common Mistakes

No error handling around plugin calls. If a plugin errors inside a hook, it can crash your host. Wrap hook invocations in pcall:

function Hooks.invoke(name, ...)
  if not Hooks[name] then return end
  for _, hook in ipairs(Hooks[name]) do
    local ok, err = pcall(hook.callback, ...)
    if not ok then
      print("hook error in", name, err)
    end
  end
end

Assuming plugins load in a specific order. Use explicit dependencies if ordering matters, or have plugins register priority callbacks to sequence themselves.

Leaking state between plugin instances. Each plugin should initialize its own state in on_load, not at module level. Module-level tables are shared across all plugin instances.

See Also