Skip to content

← Writing

engineering

Conventional Commits, End to End

· Jerwin Arnado ·

Open a year-old project and run git log --oneline. If it reads like this —

a1b2c3d fix
d4e5f6a more changes
b7c8d9e wip
e0f1a2b asdf
c3d4e5f final fix for real this time

— you already know the cost. Nobody can tell what shipped, what broke, or which commit to revert without reading every diff. Conventional Commits is the cheapest fix: a tiny grammar for the commit subject line that a human can scan and a machine can parse. No new tooling required to start — just a convention you and your team agree to type.

What Conventional Commits actually is

It’s a specification (currently 1.0.0) that says a commit message should look like:

<type>[optional scope]: <description>

[optional body]

[optional footer(s)]

That’s the whole idea. The first line carries a type (what kind of change), an optional scope (what part of the codebase), and a short description. The structure is rigid enough for a script to read, loose enough that writing it never slows you down once it’s habit.

A real one from this very site’s history:

Show per-post view count from GoatCounter

— which, tightened to spec, becomes:

feat(blog): show per-post view count from GoatCounter

Now a changelog generator knows it’s a feature, knows it touched the blog, and knows the one-line summary, all without a human in the loop.

Anatomy of a message

Part Required? Example Purpose
type yes feat The category of change — drives versioning
scope no (auth) The area touched — a module, package, or feature
description yes add passkey login Imperative, lowercase, no trailing period
body no “Explains the why…” Context the subject can’t hold
footer no BREAKING CHANGE: … Metadata: breaking changes, issue refs

Two rules carry most of the value: keep the subject ≤ 50 characters so it never truncates in git log, and write the description in the imperative mood — “add”, not “added” or “adds”. The trick: a good subject completes the sentence “If applied, this commit will ___.” “If applied, this commit will add passkey login.” Reads right.

The types

The spec mandates only two (feat and fix); the rest come from the widely-used Angular convention and are worth adopting wholesale so your whole team speaks one vocabulary:

Type Use it for Version bump
feat A new feature for the user MINOR
fix A bug fix for the user PATCH
docs Documentation only none
style Formatting, whitespace — no code-meaning change none
refactor Code change that neither fixes a bug nor adds a feature none
perf A change that improves performance PATCH
test Adding or correcting tests none
build Build system or dependencies (Composer, npm) none
ci CI config and scripts none
chore Maintenance that doesn’t touch src or tests none
revert Reverts a previous commit varies

The “version bump” column is the payoff and we’ll come back to it. The short version: feat and fix are the only types that change what your users see, so they’re the only ones that move the version number on their own.

Scope is a noun in parentheses naming the part of the codebase — pick a convention and stick to it. In a Laravel app that might be (auth), (billing), (api); in a monorepo it’s usually the package name. Keep the set small and consistent, or it stops being useful.

fix(api): return 422 instead of 500 on validation failure

Body is where the why lives. The subject says what changed; the body says what problem it solves and what you ruled out. Wrap at ~72 characters, separate it from the subject with one blank line:

fix(api): return 422 instead of 500 on validation failure

The validator threw before the response macro ran, so clients got a
generic 500 with no field errors. Catch ValidationException in the
handler and render the standard error envelope instead.

Footer carries machine-readable metadata — issue references and, most importantly, breaking changes.

Refs: #214
Reviewed-by: Jerwin

Breaking changes

This is the part that earns its keep. A breaking change is signalled one of two ways: a ! after the type/scope, or a BREAKING CHANGE: footer (or both):

feat(api)!: drop the v1 token endpoint

BREAKING CHANGE: /api/v1/token is removed. Migrate to /api/v2/auth,
which returns a Sanctum token instead of the legacy JWT.

Either marker bumps the MAJOR version, no matter the type. That ! is a one-character flag that tells every downstream consumer “read this before you upgrade” — the kind of signal the whole open-source supply chain relies on staying honest.

Why bother

Three concrete payoffs, in order of how soon you feel them:

  1. A readable history, today. git log --oneline becomes a scannable record. You can git log --grep '^feat' to list every feature, or filter by scope to see everything that touched billing. The discipline pays off the first time you bisect a regression and the commit subjects actually tell you something.
  2. An automated changelog. Tools like git-cliff or standard-version read the type and scope of every commit since the last tag and generate a grouped CHANGELOG.md — Features, Bug Fixes, Breaking Changes — with zero hand-editing.
  3. Automated semantic versioning. This is the big one. The type-to-bump mapping in the table above isn’t decoration: a release tool scans your commits and computes the next version automatically. A fix since the last release means 1.4.2 → 1.4.3. A feat means 1.4.2 → 1.5.0. Any breaking change means 1.4.2 → 2.0.0. The version number stops being a judgment call and becomes a function of what you actually merged.

That third point is why I treat the convention as non-negotiable on anything I publish. It’s the same instinct as the verification discipline AI workflows forced on me: let the machine handle the bookkeeping it’s better at, so the version number can’t drift from reality.

Good and bad, side by side

# Bad — vague, past tense, no type
fixed the login bug

# Good
fix(auth): reject expired tokens on refresh

# Bad — two unrelated changes in one commit
feat: add dark mode and fix the footer spacing

# Good — split them
feat(ui): add class-based dark mode toggle
fix(ui): correct footer spacing on mobile

# Bad — type lies about the change
chore: rewrite the entire payment flow

# Good
refactor(billing): extract the Paymongo gateway behind an interface

The recurring sin is the bundled commit — two changes, one message. Conventional Commits gently forces atomic commits, because a single message can only honestly claim one type. That pressure toward one-change-per-commit is half the benefit.

Caveats and team adoption

  • The convention is worthless if it’s not enforced. A repo where half the commits follow the spec and half say “wip” gets you the worst of both — tooling that can’t trust the history. Enforcement (a commit-msg hook, commitlint) is the natural next step; that’s its own post.
  • Don’t over-scope. A sprawling, inconsistent set of scopes is noise. If you can’t name the scope confidently, omit it — scope is optional for a reason.
  • chore is not a junk drawer. When everything becomes chore, the type column stops meaning anything. Reach for the specific type first.
  • Squash-merging? Make sure the squashed commit’s final message follows the spec — the individual branch commits matter less than the one that lands on main.

Conclusion

Conventional Commits is the rare discipline that costs almost nothing and compounds. The minimum viable adoption is one sentence: every commit subject starts with a type.

<type>(<scope>): <imperative, lowercase description ≤ 50 chars>

Start with just feat and fix. Add the rest of the vocabulary as it comes up. The day you wire a changelog or a release tool onto a year of well-typed commits — and it spits out a correct version number and a grouped changelog with no hand-editing — is the day the habit pays for every keystroke it ever cost.