Elixir Composer

Composer is an app generation tool for Elixir.

Elixir package maintainers ship generators and igniters — indispensable tools. And yet, turning a generated skeleton into a working prototype can still be a lot of work. You need to know which defaults to remove, which packages to add, how to configure them, and a dozen other things. That knowledge is scattered across blog posts, READMEs, conference talks and developers' heads. Composer aims to capture this scattered tacit knowledge into an organized cookbook that is repeatable, composable and sharable.

Composer builds on generators and Igniter to produce a skeleton that is better suited to its purpose from the start, so you reach a first working prototype sooner. Composer manages changes like:

Composer is inspired by the installer feature on AshHQ. It extends that idea so that cookbooks are easy to:

Composer is also inspired by dev-ops tools like Chef, Puppet, Ansible and Salt. Before them, configuring a server was a manual craft, built on the tacit knowledge of Ops engineers, arcane bash scripts and obscure configuration spells. Devops tools encoded that know-how as code, in cookbooks of repeatable, composable recipes, and turned provisioning into a reliable scalable process. Setting up a new application is still a manual craft. Composer applies the same approach: setup know-how lives in shared cookbooks, and every new app starts from proven recipes instead of from scratch.

Overview

Concepts

With composer, a cookbook contains recipes; recipes are lists of steps, organized in chapters; steps make changes and can render templates.

Mix tasks

Task Purpose
composer.new (composer_new archive) Create an app and apply a recipe
composer.init Make your app a cookbook and generate its recipe
composer.recipe.{list,show,run,edit} List, inspect, run and edit recipes
composer.step.{list,find,help,run,edit} List, search, inspect, run and edit steps
composer.cookbook.list List discovered cookbooks
composer.gen.{step,recipe,cookbook} Scaffold a step, recipe or cookbook
composer.cookbook.{manifest,lint} Write a cookbook's manifest; lint it
composer.completion, composer.index Shell completion
composer.lsp Language server for recipe files

Run mix composer to list them all with a one-line summary. A task run without its required arguments prints its mix help page.

Recipes

A recipe is plain data. mix composer.init generates one; edit it to taste:

# lib/composer/my_app/recipes/app.exs
[
  default: [
    shortdoc: "My app's setup",
    flags: [oban: "Add Oban"],
    steps: [
      "cb_std:phx:remove_daisy_ui",                # undo a phx.new default
      "cb_std:phx:remove_topbar",
      "cb_std:setup:gitignore",                    # project hygiene
      "cb_std:setup:credo",                        # add a package
      {"cc_lab:ash:postgres", argv: ["--all"]},  # a step with arguments
      "hex:ash",                                 # a Hex package's own installer
      {"hex:oban", if: :oban},                   # optional, behind --oban
      ":deploy_config",                          # your own step, in this cookbook
      "cb_mine/dev/tools"                        # include another recipe
    ]
  ]
]

It runs as one diff, with the flags it declares:

$ mix composer.recipe.run /app/default --oban

Getting started

The one-liner

The composer_new archive creates an app and applies a recipe in one command:

mix archive.install hex igniter_new     # 0.5.32 or later
mix archive.install hex composer_new
mix composer.new my_app cb_lab/base/completions --with phx.new

It runs igniter.new, installs composer and the cookbook as dev/test deps, commits the command line as the app's first commit, and runs the recipe. The cookbook can come from Hex, @github:, @git: or @path:, and --dry-run prints every command first. Without a recipe (mix composer.new my_app), you get an app with composer installed, ready for step 2 below. See the archive's README.

To set up an existing app by hand instead:

1. Create an app and add the dependency

mix igniter.new my_app --with phx.new
cd my_app

Add composer to your deps in mix.exs:

def deps do
  [
    {:composer, "~> 0.4.0", only: :dev},
    # ...
  ]
end
mix deps.get

2. Generate your recipe

mix composer.init

This makes your app a cookbook and writes lib/composer/my_app/recipes/app.exs, holding the recipe /app/default: every built-in step in a sensible order, each with a one-line description. Open it (mix composer.recipe.edit /app/default) and make it yours:

3. Run it

mix composer.recipe.show /app/default     # preview: docs, flags, resolved steps, warnings
mix composer.recipe.run /app/default

Nothing is applied blindly. Every reference is resolved and every step's requires checked before anything runs. The run prints a plan showing what each step resolved to, then applies all changes — your steps and package installers together — as one diff, recorded as one git commit (a dirty work tree is refused). Pass --dry-run to preview the diff, --no-commit to skip the commit.

Only dependencies come first: packages that ship an installer are added to mix.exs and fetched up front, since their installers must be on disk to run. When the run finishes, set up the database and go:

mix ecto.setup
mix phx.server

Note: the generated recipe enables the vector Postgres extension via the pg/extensions and pg/vector steps. If your local Postgres doesn't have pgvector installed, either install it or delete those two steps from your recipe.

Keep the recipe in your repo: it documents exactly how your app was set up, and you'll copy it into your next project.

Running single steps

Every step also works on its own, no recipe required:

mix composer.step.run cb_std:setup:credo,cb_std:setup:quokka
mix composer.step.run remove_daisy_ui        # a bare name searches every cookbook
mix composer.step.run hex:oban               # a package's own installer
mix composer.step.run hex:ash@github:ash-project/ash   # ... from GitHub
mix composer.step.run hex:my_lib@path:../my_lib        # ... from a local path
mix composer.step.run cc_lab:foo -- --bar   # arguments after -- go to the step
mix composer.step.run hex:ash_authentication -- --auth-strategy password  # ... or the installer

Finding steps

mix composer.cookbook.list                   # cookbooks, with step and recipe counts
mix composer.step.list --action remove       # also --cookbook, --framework, --tag
mix composer.step.find tailwind
mix composer.step.show cb_std:phx:bun          # facets, requires, and a dry run
mix composer.step.edit cb_std:phx:bun          # open its source in your editor
mix composer.recipe.list

Every listing task takes --json, for scripts and agents.

Scripts and CI

Runs are interactive by default (diff, then confirm), except composer.recipe.run, which always applies unattended. In a script or CI, pass Igniter's standard --yes flag to auto-accept:

mix composer.init --yes && mix composer.recipe.run default --no-commit

Why not just…

Addresses and Hex packages

Every address says what it is and where it resolves: / addresses a recipe and : a step; a cookbook name in front qualifies it (cb_lab/lv/base, cb_std:setup:credo), a leading / or : means the recipe's own cookbook (/lv/base, :deploy_config), and ./, ../ are relative to the recipe's chapter (./base, ../ui/buttons). A package is "hex:oban": add the package and run its own installer. Each run prints a plan telling you what every entry was:

Composer plan
  cb_std:setup:credo
  hex:oban — oban.install
  hex:nimble_options — dependency only, ships no installer
  cb_std:setup:sort_deps

Cookbook source never searches and never falls back to Hex, so installing another cookbook cannot change what a recipe means: a bare "oban" in a recipe is an error that says to write "hex:oban". On the command line a bare name searches every cookbook (mix composer.step.run credo), and two matches are an error listing both. See Addressing.

A hex: entry can pass arguments to the installer, set dependency options, and install from a version, GitHub, git or a local path:

{"hex:ash_authentication", argv: ["--auth-strategy", "password"]},
{"hex:mix_test_watch", only: [:dev, :test], runtime: false},
{"hex:live_debugger", github: "software-mansion/live-debugger", only: :dev},
{"hex:my_lib", path: "../my_lib"},
"hex:ash@3.5"                                  # the @ shorthand, as in mix igniter.install

All packages in a run still share one deps fetch and compile, and the plan shows each one's source and arguments (hex:my_lib (path ../my_lib)). See Hex packages.

Composer deliberately ships no step for packages that provide their own Igniter installer — oban, tidewave, ash and friends run upstream's installer, so upstream stays the authority. Built-in steps exist only where upstream ships nothing, and each does the minimum wiring the package's installer would do if it existed.

Built-in steps

All built-in steps live in the cb_std cookbook, grouped by directory (phx/, setup/, ecto/, pg/, cluster/, tidewave/): address them as "cb_std:<group>:<name>", e.g. "cb_std:setup:credo" for the id setup/credo. mix composer.step.list --cookbook cb_std lists them with their facets.

Id Action What it does
phx/bun add Replaces esbuild/tailwind with Bun: dep, package.json, config, watchers, aliases, tsconfig
phx/minimal_app_layout patch Replaces Layouts.app/1 with a minimal header/main layout
phx/minimal_home_page patch Replaces the phx.new marketing page with a minimal home page
phx/remixicons add Remix Icons as a Tailwind plugin with remix-* classes and a CoreComponents icon clause
phx/remove_agents_md remove Removes the generated AGENTS.md
phx/remove_daisy_ui remove Removes the :daisyui Mix dependency and CSS plugin blocks
phx/remove_live_title_suffix remove Removes the " · Phoenix Framework" title suffix
phx/remove_theme_toggle remove Removes the theme scripts, component, and usage
phx/remove_topbar remove Removes the topbar progress indicator
phx/tailwind_formatter gen HEEx formatter that sorts Tailwind classes
setup/credo add Dev/test dependency for static analysis
setup/dotenv_parser add Loads .env in runtime config; creates and gitignores .env
setup/exsync add Auto-recompilation on file changes (dev)
setup/generator_defaults config Generators default to binary_id + utc_datetime_usec
setup/gitignore config Adds the expert cache (.expert/) to .gitignore
setup/mix_env_config config config :app, env: Mix.env() for runtime environment checks
setup/mix_test_watch add Runs tests on file changes
setup/quokka add Credo-configured formatter plugin
setup/sort_deps patch Sorts mix.exs dependencies alphabetically
ecto/base_schema gen MyApp.Schema module with sensible key/timestamp defaults
ecto/force_drop config ecto.drop --force-drop in the mix alias
ecto/uuidv7 add Time-sortable UUID primary keys; updates your Schema module when present
pg/extensions gen Migration enabling citext, pg_trgm, unaccent, and vector
pg/vector add Vector search: dep, Postgrex types, config, extension migration (merges into existing extensions migration)
cluster/gigalixir gen Gigalixir deploy: buildpacks, Procfile, release migration scripts, SSL config
cluster/gigalixir/libcluster config Kubernetes clustering strategy for Gigalixir
cluster/libcluster add Node clustering: dep, supervision child, Gossip topology in dev
tidewave/tidewave_config add Creates .mcp.json pointing at a local Tidewave MCP server (follows hex:tidewave)
oban_pro add Oban Pro 1.8: Pro engine, Cron/Lifeline/Pruner/Queues services, migration (requires a license and an Oban 2.24+ setup; follows hex:oban)
echo debug Prints a message to stdout; changes nothing (for debugging recipes)

Steps that pattern-match against phx.new output warn loudly when they find nothing to change, instead of silently no-oping.

Writing your own steps

Your app is a cookbook too, so a one-off step lives next to your recipe:

mix composer.gen.step deploy_config

writes lib/composer/my_app/steps/deploy_config.ex, with every facet stubbed out, and a test:

defmodule Composer.MyApp.Steps.DeployConfig do
  use Composer.Step,
    summary: "Writes the deploy config",
    action: :gen,
    capability: [:deploy],
    stability: :stable

  @impl Igniter.Mix.Task
  def igniter(igniter) do
    Igniter.create_new_file(igniter, "config/deploy.exs", "# ...")
  end
end

Reference it as "my_app:deploy_config" (or just "deploy_config" from your own recipes). Composer.Templates, Composer.Helpers and Composer.Versions provide conveniences for common patterns: rendering template files, app/repo module names, checked file edits, migration timestamps and latest-version lookups.

Sharing steps and recipes

Steps and recipes travel in cookbook packages: a Hex package with a lib/composer/<package>/ tree. Add one as a dev dependency and its steps and recipes appear in every listing task, referenced as "<package>:<path>". Scaffold one with:

mix composer.gen.cookbook
mix composer.gen.step ash/postgres
mix composer.gen.recipe lv/base
mix composer.cookbook.manifest      # commit the manifest
mix composer.cookbook.lint          # in CI (--format github for annotations)

See the cookbooks guide for the layout, naming rules and bundling a cookbook with a framework.

Your personal recipes live in a cookbook of your own (cb_mine here): an ordinary Mix project in git, added to each app as a path dependency. See Personal cookbook for setting one up.

mix igniter.install cb_mine@path:../cb_mine --only dev,test
mix composer.recipe.list cb_mine              # every recipe in it, with its shortdoc
mix composer.recipe.run cb_mine/dev/tools
mix composer.init --from cb_lab/lv/base       # start a new app from a shared recipe

Lint reports every problem at its place in the file, and speaks the formats editors and CI read (--format compact|json|github, --file for on-save checks in Vim, Neovim, VS Code and others); see the lint guide.

For editing recipes, mix composer.lsp runs composer-ls, a language server beside your Elixir one: warm diagnostics on save (also in the files that include the saved recipe), hover, goto-definition, completion of step and recipe ids, and quick fixes. See the Composer LSP guide.

A step entry can carry options: {"cc_lab:x", argv: ["--all"]} passes arguments to the step, as -- does on mix composer.step.run, and combines with if: ({"cc_lab:x", argv: ["--all"], if: :x}). On a hex: entry, argv: goes to the package's installer.

Recipes compose: a recipe address in steps: ("cb_mine/dev/tools") includes another recipe's steps in place, to any depth, optionally behind a flag ({"/deploy/gigalixir", if: :gigalixir}); "./base" names a recipe in the same chapter. At run time the tree is flattened into one ordered step list — packages from the whole tree are deduped and fetched up front, a single diff covers the entire run, and flags declared anywhere in the tree surface on the task you invoke (any other --flag is rejected). See the recipes guide.

Steps apply in list order, package installers included, so a step that patches what an installer wrote just goes after it:

"hex:oban",
"my_team:oban_tweaks"

{:queue, "some.task", []} is there for work that genuinely has to run against the applied project on disk, after everything else.

Shell completion

mix composer.completion bash > ~/.complete/mix_composer.bash   # or zsh, fish
You type Completed by From
mix compo<TAB> mix_completions the task cache
mix composer.<TAB> mix_completions the task cache
mix composer.recipe.show <TAB> composer's script _build/composer/index
mix help composer.rec<TAB> mix_completions (fork) the task cache

Composer's script completes step and recipe addresses from a plain-text index, so it never starts the BEAM, and hands every other mix completion to mix_completions. In bash, source it after mix_completions' script.

The script hints, once per session, when either is stale. The experimental cb_lab:completions:composer and cb_lab:completions:mix steps install composer's script and mix_completions for you. See the completion guide for how it all fits together.

License

MIT — see LICENSE.md.