luaguides

Designing Plugin Architectures in Lua

A plugin architecture lets other developers extend your application without touching your core code. Lua’s module system 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

Your host application loads it via require:

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:

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.

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:

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:

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:

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:

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.

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:

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.

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.

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