Creating a Phoenix Project Template with Cookiecutter


While Phoenix provides mix phx.new for quick project creation, team collaboration and personal preferences often call for consistent code conventions, pre-configured dependencies, and standardized project structures.

This article introduces how to build a reusable Phoenix project template with Cookiecutter.

Requirements

Tool Version
Cookiecutter 2.6+
Elixir 1.18+
Erlang 27.2+
Phoenix 1.17+
Python 3.10+

Installation:

# macOS
brew install cookiecutter

# Linux
pip install --user cookiecutter

Template Initialization

Create Project Structure

mkdir cookiecutter-phoenix && cd cookiecutter-phoenix

Configuration File

cookiecutter.json defines the template variables:

{
    "app_name": "my_app",
    "app_module": "{{ cookiecutter.app_name | replace('_', ' ') | title | replace(' ', '') }}",
    "author_name": "Sean Gong",
    "app_version": "0.1.0",
    "elixir_version": "1.18.2-otp-27",
    "erlang_version": "27.2.4"
}

Generate Base Project

mix phx.new awesome_app --no-html \
    --no-assets \
    --no-gettext \
    --no-dashboard \
    --no-live \
    --no-mailer

# Version management file
cat <<EOF > awesome_app/.tool-versions
erlang {{cookiecutter.erlang_version}}
elixir {{cookiecutter.elixir_version}}
EOF

Template Customization

Core Strategy

Strategy Description
File/folder renaming Replace awesome_app with the user-provided project name
Content replacement Use regex to replace module names, config items, etc.
Template syntax escaping Prevent Jinja2 from misinterpreting Elixir code

Batch Replacement Script

# 1. Rename files and folders
find ./awesome_app -depth -name '*awesome_app*' -execdir sh -c 'mv "$1" "$(echo "$1" | sed "s/awesome_app/{{cookiecutter.app_name}}/g")"' _ {} \;

# 2. Replace module names in files
find ./ -type f -exec perl -pi -e 's/AwesomeAppWeb/{{ cookiecutter.app_module }}Web/g' {} +
find ./ -type f -exec perl -pi -e 's/AwesomeApp/{{ cookiecutter.app_module }}/g' {} +

# 3. Check for any omissions (use VSCode search & replace)

Template Syntax Escaping

Single-line escaping:

mod: {{'{'}}{{ cookiecutter.app_module }}.Application, []{{'}'}}

Multi-line escaping:

{% raw -%}
{:telemetry_poller, measurements: periodic_measurements(), period: 10_000}
{% endraw -%}

Extension Development

Register Custom Functions

Through Jinja2 extensions, you can add custom global functions and filters:

# local_extensions.py
#!/usr/bin/env python3
from jinja2.ext import Extension
import secrets
import urllib.request
import json

deps_default_versions = {
    'oban': "2.19",
}

def latest_package_version(name: str) -> str:
    """Get the latest version (major.minor) of a package from hex.pm"""
    try:
        with urllib.request.urlopen(f"https://hex.pm/api/packages/{name}") as response:
            data = json.loads(response.read().decode())
            version = data['releases'][0]['version']
            major_minor = '.'.join(version.split('.')[:2])
            return f"~> {major_minor}"
    except Exception as e:
        print(f"Failed to get {name} version: {e}")
        return deps_default_versions[name]


class CustomExtension(Extension):
    def __init__(self, environment):
        super().__init__(environment)
        # Generate secure keys
        environment.globals['gen_secret'] = lambda v: secrets.token_urlsafe(v)
        # Get latest version
        environment.globals['latest_version'] = latest_package_version

Enable Extension

Add to cookiecutter.json:

{
  "app_name": "my_app",
  "_extensions": ["local_extensions.CustomExtension"]
}

Usage Example

# config/dev.exs
config :{{ cookiecutter.app_name }}, {{ cookiecutter.app_module }}Web.Endpoint,
  http: [ip: {127, 0, 0, 1}, port: 4000],
  check_origin: false,
  code_reloader: true,
  debug_errors: true,
  secret_key_base: "{{ gen_secret(48) }}",
  watchers: []

Optional Module: Adding Oban

Using the task queue Oban as an example:

1. Add Configuration Variable

{
  "use_oban": "y"
}

2. Conditional Dependency

# mix.exs
defp deps do
  [
    {% if cookiecutter.use_oban == 'y' -%}
    {:oban, "{{ latest_version('oban') }}"},
    {% endif -%}
  ]
end

3. Conditional Configuration

# config/config.exs
{% if cookiecutter.use_oban == 'y' -%}
config :{{ cookiecutter.app_name }}, Oban,
  engine: Oban.Engines.Basic,
  queues: [default: 10],
  repo: {{ cookiecutter.app_module }}.Repo,
  prefix: "oban"
{% endif -%}

# config/test.exs
{% if cookiecutter.use_oban == 'y' -%}
config :{{ cookiecutter.app_name }}, Oban, testing: :manual
{% endif -%}

# lib/{{cookiecutter.app_name}}/application.ex
def start(_type, _args) do
  children = [
    {{ cookiecutter.app_module }}.Repo,
    {% if cookiecutter.use_oban == 'y' -%}
    {Oban, Application.fetch_env!(:{{ cookiecutter.app_name }}, Oban)},
    {% endif -%}
    # ...
  ]
end

4. Conditional File Generation

# hooks/post_gen_project.py
#!/usr/bin/env python
from pathlib import Path

def remove_oban_files():
    """Remove Oban migration files when the user opts out"""
    oban_path = Path(
        "apps",
        "{{cookiecutter.app_name}}",
        "priv",
        "repo",
        "migrations",
        "20250305072314_add_oban.exs"
    )
    if oban_path.exists():
        oban_path.unlink()


def main():
    if "{{cookiecutter.use_oban}}".lower() != 'y':
        remove_oban_files()


if __name__ == "__main__":
    main()

Testing and Verification

# Clean and regenerate
rm -rf my_app
cookiecutter ./cookiecutter-phoenix --no-input

# Inspect the generated project
code my_app
Parameter Description
--no-input Use default values, skip interactive prompts

Input Validation

# hooks/pre_gen_project.py
#!/usr/bin/env python

app_name = "{{ cookiecutter.app_name }}"
assert app_name == app_name.lower(), f"'{app_name}' must be all lowercase"

Summary

Through these steps, you can:

  • Standardize your team’s project structure
  • Pre-configure common dependencies (such as Oban, Req, etc.)
  • Ensure code convention consistency
  • Quickly bootstrap new projects

Complete template source: cookiecutter-phoenix