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