---
title: "Vale Reviews Your Comments In The Code"
url: https://www.exakat.io/vale-reviews-your-comments-in-the-code/
date: 2026-09-20
modified: 2026-09-20
lang: en
author: "dams"
description: "Vale Reviews Your Comments In The Code Static analysis will tell you a function's return type is wrong down to the last nullable. It doesn't tell you that the docblock..."
categories:
  - "Code auditing"
tags:
  - "comment"
  - "php"
  - "spelling"
image: https://www.exakat.io/wp-content/uploads/2026/09/vale.png
word_count: 3153
---

# Vale Reviews Your Comments In The Code

# Vale Reviews Your Comments In The Code

Static analysis will tell you a function's return type is wrong down to the last nullable. It doesn't tell you that the docblock two lines above it opens with "This class is responsible for," restates the method signature it's sitting on, or, this happened, in a project with genuinely careful code review, is written half in French. [Vale](https://vale.sh/) will. It pulls the prose out of source code and runs style rules over it, the same way a linter runs rules over syntax. Nobody pay designs a codebase's comments the way they design its types, which is exactly why nobody's checking them.

I pointed it at [Cecil](https://cecil.app/), a mature, well-documented static site generator, 126 PHP files. Its docblocks are better than most projects'. They are full sentences, bullet lists, fenced code samples. Vale ran on it, and found the following, in `src/Asset.php`, `src/Converter/Parsedown.php`, `src/Generator/VirtualPages.php` and `src/Step/Menus/Create.php`:

| Location | Finding | |
| -------- | ------- | --- |
| `src/Asset.php:790` | Returns image size **`informations`**. | "information" has no plural in English |
| `src/Asset.php:853` | Remove **`redondant`** '/thumbnails/…/' in the path. | French spelling of "redundant" |
| `src/Converter/Parsedown.php:439` | **`abord`** if InlineImage is an animated GIF | "abort" |
| `src/Generator/VirtualPages.php:66` | **`abord`** if the page id already exists | |
| `src/Step/Menus/Create.php:129` | **`abord`** if entry is not enabled | |

Same French leak, three separate files: I recognize the consistency as, in French, information may be plural: there is no such thing as a `pièce d'information`. I'm not going to pretend I'm neutral about a project's comments quietly code-switching into French, I do that too. The leak isn't the point. The point is that it survived a genuinely good review process for as long as the project has existed, because nothing in that process was built to catch it. Static analysis checks types. Code prettifier checks formatting. None of them opens a comment and reads it. Comments are for human consumption.

Vale finding range from simple case typo to actual intent. For example, it found ten places where the project's own docs write *Twig* and the docblocks write *twig*. And alter, forty class docblocks that open with "This class is responsible for…" instead of saying what the class does. This is the docblock equivalent of a job interview answer that restates the question before answering it. Let's see how it can be applied to a code base.

## Getting Vale ready

Vale is a single Go binary, which means installing it has nothing to do with Composer:

# macOS
brew install vale

# Debian / Ubuntu — see pkg.haus for the archive setup
sudo apt install vale

# Arch
sudo pacman -S vale

# Windows
winget install -e --id errata-ai.Vale

For a PHP project you'll want the version pinned alongside everything else, so your laptop and CI agree on which rules exist: Vale has versions every week, so there might be evolution within a short time range. Vale ships through the usual registries too. Each one just downloads the same release binary and puts `vale` on your path:

# any project, any language
mise use vale@3.22.0

# or via npm / PyPI, if you already have one of them
npm install --save-dev @vvago/vale
pip install vale

$ vale --version
vale version 3.22.0

The one place Vale genuinely isn't packaged is Composer, and it isn't an oversight: it's a Go binary, not a PHP library, and PHP's package manager has no business fetching it. Install it beside Composer, not through it.

## Pointing it at real code

For a life size test, we're going to use [Cecil.app](https://cecil.app/), the famous PHP static website generator. For a full disclosure, it is authored by [Arnaud Ligny](https://phpc.social/@arnaud@gazuji.com), and available in open source.

There are 126 PHP files under `src/`, with docblocks rich enough to make this worth doing: they have full sentences, bullet lists, fenced code samples, in English. For example:

/**
* The main Cecil builder class.
*
* This class is responsible for building the website by processing various steps,
* managing configuration, and handling content, data, static files, pages, assets,
* menus, taxonomies, and rendering.
* It also provides methods for logging, debugging, and managing build metrics.
*
* ```php
* $config = [
* 'title' => "My website",
* 'baseurl' => 'https://domain.tld/',
* ];
* Builder::create($config)->build();
* ```
*/
class Builder implements BuildContextInterface, LoggerAwareInterface

That docblock is already a small preview of everything below: a summary that restates its own class name, a weasel word `various`, and a fenced code sample that had better not get treated as prose.

## Teaching Vale that this is Markdown wearing a PHP hat

Vale reads PHP natively: `//`, `#` and `/* … */` comments come back as scoped blocks, and everything that isn't a comment is discarded before any rule runs. That part needs no configuration. The two lines that do all the real work are the format association and the file glob:

StylesPath = .vale
MinAlertLevel = suggestion
Packages = write-good, proselint, Microsoft

[formats]
php = md

[*.php]
BasedOnStyles = Vale, write-good, proselint, Microsoft

`php = md` is the line that makes docblocks legible, and for PHP specifically it isn't a nicety. It's the difference between a usable run and a useless one. With the association in place, Vale strips the leading `*` from every line of a block comment, so a docblock reads as paragraphs and lists instead of one giant bullet point; treats fenced ```php``samples inside docblocks as code and skips them, so`Builder::create($config)->build();`doesn't get flagged for "using 'is'"; and unlocks`TokenIgnores`and`BlockIgnores`, which are otherwise unavailable on source files at all — both of them earn their keep two sections from now.

$ mkdir -p .vale
$ vale sync
SUCCESS Synced 3 package(s) to '/home/you/cecil/.vale'.

`StylesPath` has to exist before Vale will run at all. It will not create the directory on your behalf, and a missing one fails with `E201 Invalid value` pointing at `.vale.ini`, which reads exactly like a syntax error in your config and is, in fact, a missing folder.

## The first vale run is a firehose, and that's normal

$ vale src/
…
✖ 765 errors, 431 warnings and 1036 suggestions in 126 files.

real 0m1.517s

2,232 alerts, almost all of them noise, produced in a second and a half. At least, it tells you Vale's speed was never the problem. A single file shows why:

$ vale src/Builder.php

src/Builder.php
4:14 suggestion Try to avoid using 'is'. write-good.E-Prime
6:4 error Consider using the '©' symbol instead of… proselint.Typography
6:8 error Did you really mean 'Arnaud'? Vale.Spelling
9:14 warning 'was distributed' may be passive voice. write-good.Passive
27:15 warning 'is responsible for' is too wordy. write-good.TooWordy
27:69 warning 'various' is a weasel word! write-good.Weasel
51:46 error Use 'aren't' instead of 'are not'. Microsoft.Contractions
112:13 error Did you really mean 'bool'? Vale.Spelling

There are three genuinely different problems, sitting in that list, and each needs a different fix:

| Rule | Hits | Why it fires |
| ---- | ---- | ------------ |
| `write-good.E-Prime` | 588 | Bans the verb "to be," full stop. It is a stylistic experiment some writer once picked a fight with, not a standard. It is useless for API docs, which exist to state facts, several of which are facts about what things are. |
| `Vale.Spelling` | 525 | This error reports mostly identifiers, such as `$baseurl`, `getFile()`, `min_word_count`, and not prose. Vale doesn't yet know the difference between a sentence and a variable name. May be the `$` sign is not obvious enough? |
| `Microsoft.Passive` + `write-good.Passive` | 271 + more | Two packages independently policing the passive voice, so every real hit gets counted, and complained about, twice. |
| `proselint.Typography` | 139 | The `(c)` in the license header, once per file, 126 times over. |
| `Microsoft.Contractions` | 49 | Demands "aren't" over "are not." That's Microsoft's house style leaking into your codebase's opinions, not yours. |

Vale is not telling you the comments are bad. Vale is telling you it hasn't been told what this project is yet. It is the same conversation every static analyzer has with a codebase on day one, just conducted in English instead of PHPDoc types.

## Four moves, in the order that pays off fastest

### Turn off the rules that were never arguing with you

E-Prime alone is a quarter of the noise; contractions and the spell-out-your-acronyms rules are house-style opinions a docblock has no obligation to hold.

[*.php]
BasedOnStyles = Vale, write-good, Microsoft

write-good.E-Prime = NO
Microsoft.Contractions = NO
Microsoft.GeneralURL = NO
Microsoft.Acronyms = NO
Microsoft.QuestionMarks = NO

This new configuration drops `proselint` from `BasedOnStyles` and `Packages` entirely. These are typography rules, aimed at published essays, and once the license header is excluded, it has nothing left worth saying about a codebase.

### Give the project a vocabulary

`Vocab` names a folder under `StylesPath/config/vocabularies/`. `accept.txt` lists terms the spellchecker should already know; `reject.txt` lists terms that should be flagged wherever they appear. Both take one case-sensitive regex per line.

`.vale/config/vocabularies/Cecil/accept.txt`

Cecil
Symfony
Twig
Parsedown
Imagick
libvips
Phar
Composer
frontmatter
baseurl
slugify
[Pp]aginator
[Cc]onfig
[Bb]ool(ean)?
[Hh][Tt][Mm][Ll]
[Uu][Rr][Ll]
deduplicat(e|ion)

And the inverse list, where, satisfyingly, the French spelling leak gets caught for good:

`.vale/config/vocabularies/Cecil/reject.txt`

abord
informations
redondant

**One gotcha worth knowing before you write this list yourself**: every entry in `accept.txt` doubles as a canonical-spelling rule via `Vale.Terms`. Write `html` in the list, lowercase, and Vale will start insisting you write `HTML` as `html` everywhere else. 386 new alerts appeared the first time I tried it, including `Use 'url' instead of 'URL'`, forty-six separate times, from a spellchecker that had until that point been perfectly reasonable. Either write the entry in the casing you actually want, or spell it case-insensitively as `[Hh][Tt][Mm][Ll]`. Used on purpose, this is the exact feature that produced the ten `Use 'Twig' instead of 'twig'`findings above.

### Tell it that code sitting inside a sentence is still code

Most of the remaining spelling alerts are identifiers written bare in prose. `TokenIgnores` takes a comma-separated list of regexes and strips each match before any rule sees the line:

TokenIgnores = (\$[A-Za-z_]\w*), (`[^`]+`), (@\w+[^\n]*), \
([A-Za-z]+\\[\w\\]+), (\w+\(\)), \
([a-z]+_[a-z_]+), ([a-z]+[A-Z]\w*)

This is where we make the PHP variable syntax, with its leading `$` sign active. And, of course, this is not the only syntax we want to apply. So, left to right: variables, backticked spans, annotation lines (`@param`, `@return`, `@see` and the rest), namespaced class names, function calls, `snake_case`, `camelCase`. Spelling alerts drop from 525 to 70, and what's left is almost entirely real. The trailing `\` line continuations are valid `.vale.ini` syntax; the value parses identically to writing the same regexes on one very long line.

### Exempt the paragraph nobody's ever going to rewrite

Cecil carries the same eight-line license header in all 126 files. Every Open Source project stats its code file with a licence reminder. Left in, they contribute a passive-voice warning, a typography error and two spelling errors per file. It counts 500 alerts spent reviewing a block of legal boilerplate nobody involved has the authority to change.

BlockIgnores = (?s)This file is part of Cecil\..*?source code\.

**The gotcha here is subtler than it looks**: the obvious regex starts `/\*\*` or `\* This file…`, and matches nothing, because step three already happened by the time `BlockIgnores` runs. The `php = md` association strips the leading asterisks *before* the ignore patterns are evaluated, so `BlockIgnores` is looking at the comment body as plain paragraphs, not as a raw block comment. Write the pattern against that, and add `(?s)` so `.` crosses the blank lines in between.

before tuning ████████████████████████████████████████ 2,232
after tuning ████████ 432

What a clean we just did. Along the way, we have learnt about the code base, and the Vale configuration. Now, what's left is 144 passive-voice warnings, 70 spelling alerts, 69 wordiness warnings, 13 casing findings and 5 rejected terms: oof. Yet, that is a list a human being could actually sit down and work through in an afternoon, which was never true of the first number.

## Writing rules for docblocks specifically

Every comment Vale extracts carries a scope. It is a one-line comment is `text.comment.line.php`, a block comment `text.comment.block.php`. A rule that declares one of those runs only there, which is what makes it possible to hold a published API docblock to a stricter standard than a throwaway `//` note two lines below it.

A style is just a folder of YAML under `StylesPath`. The first rule targets block comments only:

`.vale/Docblock/Summary.yml`

`.vale/Docblock/Marker.yml`

Add `Docblock` to `BasedOnStyles` and it runs alongside the packages. Applied to Cecil, the summary rule fires 40 times, every single one on a class or method docblock that opens by re-announcing the thing it's already attached to:

$ vale --filter='.Name matches "Docblock.*"' src/

src/Builder.php
27:4 warning Start the summary with a verb: 'This class' repeats… Docblock.Summary
271:8 warning Start the summary with a verb: 'This method' repeats… Docblock.Summary

src/Command/AbstractCommand.php
36:4 warning Start the summary with a verb: 'This class' repeats… Docblock.Summary

`--filter` is worth remembering for itself: it takes an expression over the alert's fields, so `--filter='.Level == "error"'` or `--filter='.Name in ["Vale.Avoid", "Vale.Terms"]'` lets you work through one category at a time without touching the config file at all.

Drop this fourteen-line file somewhere and every rule type fires on it at once, in miniature:

$ vale demo.php

demo.php
4:4 warning Start the summary with a verb: 'This class' repeats… Docblock.Summary
4:15 warning 'is responsible for' is too wordy. write-good.TooWordy
4:47 warning 'various' is a weasel word! write-good.Weasel
4:60 error Avoid using 'informations'. Vale.Avoid
6:4 warning 'It is' is too wordy. write-good.TooWordy
6:10 warning Remove 'very' if it's not important to the meaning. Microsoft.Adverbs
6:40 warning 'is invalidated' may be passive voice. write-good.Passive
10:8 error Leftover 'TODO' marker: link an issue or remove it. Docblock.Marker
10:14 error Avoid using 'abord'. Vale.Avoid

✖ 5 errors, 7 warnings and 0 suggestions in 1 file.

Now, that demo file is trying too hard, deliberately, but every fault it's committing showed up for real, somewhere in Cecil's 126 files, once each.

## Putting it in CI without picking a fight with day one

Vale exits with a non-zero value when any alert at or above `MinAlertLevel` is found, so `--minAlertLevel` is the severity dial. The arrangement that actually survives contact with an existing codebase is to fail the build only on what's been explicitly declared unacceptable. This is the case with rejected terms, leftover markers, wrong casing. On the other hand, it should still be printing the advisory warnings for anyone who wants to read them.

`.github/workflows/vale.yml`

Or, without the action, two lines suffice in any runner:

vale sync
vale --minAlertLevel=error src/ # exit 1 on errors, warnings still printed

One flag worth knowing on a first adoption: `--output=JSON`. Aggregating that by rule name is exactly how the table earlier in this piece got built, and it's the fastest way to decide what to switch off next, rather than guessing.

vale --output=JSON src/ | jq -r '.[][] | .Check' | sort | uniq -c | sort -rn

144 write-good.Passive
70 Vale.Spelling
69 write-good.TooWordy
40 Docblock.Summary
24 Microsoft.Quotes

On a large, unreviewed codebase, start with `MinAlertLevel = error` and an empty `reject.txt`, so the build is green from the first commit. Every time the team actually agrees on a rule, like a banned term, a house spelling, a docblock convention, promote it to an error. The advisory warnings sit in the log the whole time, visible, opinionated, and blocking absolutely nothing.

## The finished config

Here is the configuration file that brought down 2,232 alerts down to 432.

`.vale.ini`

StylesPath = .vale
MinAlertLevel = warning
Packages = write-good, Microsoft
Vocab = Cecil

[formats]
php = md

[*.php]
BasedOnStyles = Vale, write-good, Microsoft, Docblock

# The licence header, repeated in all 126 files. Matched against the
# comment body after the leading asterisks have been stripped.
BlockIgnores = (?s)This file is part of Cecil\..*?source code\.

# Identifiers, annotations and code spans are not prose.
TokenIgnores = (\$[A-Za-z_]\w*), (`[^`]+`), (@\w+[^\n]*), \
([A-Za-z]+\\[\w\\]+), (\w+\(\)), \
([a-z]+_[a-z_]+), ([a-z]+[A-Z]\w*)

# House-style rules a docblock has no reason to follow.
write-good.E-Prime = NO
Microsoft.Contractions = NO
Microsoft.GeneralURL = NO
Microsoft.Acronyms = NO
Microsoft.QuestionMarks = NO

cecil/
├── .vale.ini
├── .vale/
│ ├── config/vocabularies/Cecil/
│ │ ├── accept.txt
│ │ └── reject.txt
│ ├── Docblock/
│ │ ├── Summary.yml
│ │ └── Marker.yml
│ ├── Microsoft/ # vale sync
│ └── write-good/ # vale sync
└── src/

Commit `.vale.ini`, the vocabularies and your own `Docblock/` rules to the repository. Then, gitignore the synced package folders and let `vale sync` refill them on every machine and every CI run.

For reference, the four PHP comment forms and where they land:

| Syntax | Scope |
| ------ | ----- |
| `// line comment` | `text.comment.line.php` |
| `# line comment` | `text.comment.line.php` |
| `/* inline */` | `text.comment.line.php` |
| `/** docblock */` | `text.comment.block.php` |

Note that scopes match by containment, not by prefix: a rule scoped `comment` catches all four forms, `comment.block` catches docblocks in any language Vale understands, and `text.comment.block.php` narrows all the way down to PHP docblocks alone.

## Who read the comments anyway?

PHP has spent the last decade getting steadily better at making the compiler check things a human used to have to remember. Types, nullability, exhaustiveness, the whole run of the type-system work that keeps closing gaps the language left open a version or two earlier. The same engine has always striped the source code of comments and whitespaces, as they are useless to code execution, event the PHPdoc with extra types. A function can be fully typed, covered, statically verified down to the last edge case, and still be documented by a comment that lies about what it does, restates its own signature, or, as it turns out, even in a codebase good enough to be worth cloning as an example, applies grammar from a different language.

Nobody has time to audit comments, because nothing forces the question the way a red squiggly line under a type mismatch does. Vale doesn't fix that asymmetry so much as it makes the asymmetry visible for the first time: 2,232 things a well-reviewed project's comments were quietly getting away with, tunable down to 432 worth a person's actual attention. Whether prose ever gets the same treatment types did, checked by default, invisible when it's clean, argued about only at the edges, or whether "the comment sounds right" just stays a taste nobody automates, is the same open question this project's own docblocks have been answering, one `informations` at a time, since before anyone thought to ask.