The following standard applied to the front code (Vue, JS, CSS) of our Symfony projects and also for full front project without Symfony (Vanilla Javascript, Typescript, Full Vue, Vue SPA, ...).
Documentation only. This bundle ships no front configuration: nothing below is copied by the recipe. This page states the principle and the reference setup each project type applies.
Package manager. The commands below use
npm, the package manager we use and declare in thepackageManagerfield ofpackage.json. Seejs.package-managerfor why, and for what is accepted instead.
| Tool | Scope |
|---|---|
| Prettier | Code style via formatting : indentation, quotes, line breaks, Tailwind class order |
| ESLint | Substance, outside typing: JS and Vue rules, project conventions |
vue-tsc |
Typing, TypeScript projects only (see TypeScript) |
One tool, one responsibility. No overlap means no contradictory reports and no fix loop between the two tools. A failure names its owner: form goes to Prettier, code to ESLint, types to the type checker. And each tool is configured and upgraded on its own.
Our choice relies on the tool already used in the Vue.js ecosystem, which they also recommend in their documentation about Formatting, and which is the reference shipped by the create-vue scaffolding tool.
Our research also showed that other major frameworks and projects rely on it for how simple it is to set up, namely
- React,
- Tailwind CSS and
- GitLab.
The tool is easily extended through plugins, which adapt formatting to the technical context it applies to. We use the official Tailwind class sorter, see the Tailwind CSS section.
In short, this tool guarantees an opinionated setup with few options: little to debate, and the same output everywhere.
We followed the same reasoning for this tool, fully used in the Vue.js and Vite.js ecosystem, and here again it is the reference shipped by create-vue.
Both choices are therefore driven by create-vue which, being the official Vue project scaffolding tool maintained by the Vue team, guarantees that we always stay aligned with our ecosystem's best practices.
We also use the Plugin Vue to detect wrong use of Vue.js Directives and violation of Style Guide
@antfu/eslint-config, which brings rules and formatting together in a single tool, was the other candidate.
We discarded it because it is a personal configuration (often mention on other font lib, but used by no official Vue repository) and it imposes its own style opinions. That choice would also cut us off from the plugins dedicated to the other front libraries, such as the Tailwind class sorter.
ESLint can do formatting too, but we adjust its configuration to disable that behaviour so that it stays entirely handled by Prettier.
We use the eslint-config-prettier config, imported as skipFormatting and
placed last in the ESLint config: it turns off every ESLint rule duplicating or conflicting with Prettier (core rules and
vue/ layout rules).
This package also provides a CLI to test and validate that no rule is left in conflict:
npx eslint-config-prettier <file.js> <file.vue>
To be sure the command compares every rule, pass one file of each extension handled by the config (js, vue, ts, ...).
It prints No rules that are unnecessary or conflict with Prettier were found. when there is no error.
Why
eslint-config-prettier/flatinstead of@vue/eslint-config-prettier/skip-formatting? Because the Vue package was a transition package which is not meant to be maintained. We therefore chose to useeslint-config-prettier/flatlike create-vue, which made that change at the beginning of 2026, cf. PR #897.
This section is the framework agnostic base. Each following section adds what a given technical context requires on top of it.
Command to run to add the dependencies to your package.json:
npm install --save-dev eslint @eslint/js eslint-config-prettier prettierList of the commands to add in the package.json for each tool, plus the global validation one:
"scripts": {
"lint": "eslint . --fix --cache",
"lint:check": "eslint .",
"lint:baseline": "rm -f eslint-suppressions.json && eslint . --suppress-all",
"format": "prettier --write .",
"format:check": "prettier --check .",
"validation": "npm run lint:check; s=$?; npm run format:check && exit $s"
}- The
:checkcommands only report, they don't apply fix. validationruns both checks, shows both reports, fails if either fails. Your CI qualimetry job can directly call this command.--cachewrites a.eslintcachefile, to add to the.gitignore.
Configuration prettier.config.mjs
export default {
semi: false,
singleQuote: false,
printWidth: 100,
singleAttributePerLine: true,
}semi,printWidth: Directly takken from create-vue template.singleQuote: false: deviation. Prettier keeps the quote needing fewest escapes; French strings full of apostrophes would end up mixed.singleAttributePerLine: Vue style guide, priority B.- Any missing option = Prettier default (
tabWidth: 2,trailingComma: "all",htmlWhitespaceSensitivity: "css", ...).
How to ignore files in Prettier
Prettier already reads .gitignore and .prettierignore (the --ignore-path option defaults to both), so generated code stays
out on its own. A .prettierignore is only needed for what is versioned but must not be reformatted, typically third-party
assets:
# jsVectorMap map data, generated and minified: third-party data, not our code.
assets/scripts/dist
Ignoring is not the way to keep a scope tight: a wide glob passed on the command line is expanded before those rules apply, and that traversal fails on directories like
var/that the user cannot read. If that your case, point the commands at the front directories instead (see the Symfony section).
Configuration eslint.config.mjs
import js from "@eslint/js"
import { defineConfig } from "eslint/config"
import skipFormatting from "eslint-config-prettier/flat"
export default defineConfig([
{ name: "app/files-to-lint", files: ["**/*.{js,mjs}"] },
js.configs.recommended,
skipFormatting,
])- The
filesentry declares which extensions ESLint picks up when a directory is passed on the command line: without it, only.jsis linted. The sections below extend it (.vue,.ts). skipFormattingstays last: on a shared rule, the last config wins.
How to ignore files in ESLint
ESLint only ignores node_modules/ and .git/ by default. Two helpers cover the rest,
documented here:
import path from "node:path"
import { fileURLToPath } from "node:url"
import { defineConfig, globalIgnores, includeIgnoreFile } from "eslint/config"
const gitignorePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".gitignore")
export default defineConfig([
includeIgnoreFile(gitignorePath, "Imported .gitignore patterns"),
globalIgnores(["phpstorm.config.js"], "app/files-to-ignore"),
// ...
])includeIgnoreFilereuses the.gitignore, so generated code is declared in one place only.globalIgnoreshandles what is versioned but must not be linted.
Extra packages to add:
npm install --save-dev eslint-plugin-vue vue-eslint-parser
vue-eslint-parser, the parser that splits a.vuefile to expose its template and its script to ESLint, is a peer dependency ofeslint-plugin-vuethat the config imports directly, so it is declared explicitly. npm and pnpm install peer deps by default, Yarn doesn't, which is one of the reasons why it is excluded.
Impact on the ESLint config, .vue added to the linted extensions and the preset spread after the JS rules:
// eslint.config.mjs
import pluginVue from "eslint-plugin-vue"
{ name: "app/files-to-lint", files: ["**/*.{js,mjs,vue}"] },
// ...
...pluginVue.configs["flat/strongly-recommended-error"],Why the flat/strongly-recommended-error level?
Out of the eslint-plugin-vue presets, essential (the create-vue default) only catches
errors. strongly-recommended adds the consistency rules usually left aside, whose absence lets component writing style drift
from one dev to another:
vue/prop-name-casing: camelCase prop names;vue/v-on-event-hyphenation: hyphenated event names in templates;vue/require-default-prop: default for every optional prop (default: undefinedwhen none makes sense).
The -error variant is needed because the preset declares those rules as warn: a warning never enters the baseline and never
fails lint:check.
Its 12 layout rules don't conflict with Prettier: 11 are turned off by skipFormatting, and vue/first-attribute-linebreak requires what
Prettier already produces.
Why not the recommended level? It adds 8 more rules, which report 752 violations measured over two of our codebases. 84% of
them are vue/attributes-order, an ordering convention inside the tag
that means rewriting every existing template for no functional gain, and which the Vue style guide itself only ranks
priority C ("minimize arbitrary choices"). Most of the rest is
vue/no-v-html, whereas our v-html render back-end content that we control. The six remaining rules report next to nothing.
Extra package to add:
npm install --save-dev globals
The scope of the lint and format commands must be narrowed to the only directory holding front code, assets, which gives in package.json:
"scripts": {
"lint": "eslint assets --fix --cache",
"lint:check": "eslint assets",
"lint:baseline": "rm -f eslint-suppressions.json && eslint assets --suppress-all",
"format": "prettier --write assets",
"format:check": "prettier --check assets",
"validation": "npm run lint:check; s=$?; npm run format:check && exit $s"
}A project never validates vendor/: a bundle validates its own front code from its own repository.
Specific use of globals in the context of using FOSJsRoutingBundle
Impact on the ESLint config: the front code runs in the browser, and Twig injects globals that must be declared, otherwise
no-undef reports them.
// eslint.config.mjs
import globals from "globals"
{
name: "app/globals-browser",
languageOptions: {
globals: {
...globals.browser,
// Adapter of Ziggy's `route` function to FOSJsRoutingBundle, set on `window` by the Twig layout.
route: "readonly",
},
},
},The root config files (vite.config.mjs, phpstorm.config.js) stay out of that scope, so there is nothing to declare for Node.
Widening the scope to them means adding their globals too, as they don't run in the browser:
// eslint.config.mjs
{
// Config files run under Node: `vite.config.mjs` reads `__dirname`.
name: "app/globals-node",
files: ["**/*.config.{js,mjs}"],
languageOptions: { globals: { ...globals.node } },
},Inertia pages and their layouts are not reusable components: resolved by their path (the import.meta.glob of the main.js
entries), they receive their props from the Symfony controller. Two rules have no object there, while the components remain
subject to both:
// eslint.config.mjs
{
name: "app/inertia-pages-and-layouts",
files: ["**/scripts/pages/**/*.vue", "**/scripts/layout/**/*.vue"],
rules: {
"vue/multi-word-component-names": "off",
"vue/require-default-prop": "off",
},
},vue/multi-word-component-names: they are never written as a tag, so the collision with a native HTML element that the rule prevents cannot happen.vue/require-default-prop: the props are the controller's contract, not a component API.required: truewould trigger a Vue warning as soon as the back sendsnull, and adefault: undefinedon every prop would change nothing at runtime.
Extra package to add:
npm install --save-dev prettier-plugin-tailwindcss
The official class sorter enforces one canonical order for the utility classes, which removes any debate on where a class goes.
Impact on the Prettier config:
// prettier.config.mjs
plugins: ["prettier-plugin-tailwindcss"],
tailwindStylesheet: "./assets/styles/app.css",- The sorter must always be the last plugin of the list, as it has to run after the other Prettier plugins.
- Tailwind 4 has no
tailwind.config.jsanymore, the theme lives in CSS:tailwindStylesheetgives the sorter the CSS entry file importing it. Without it, the sorter doesn't know our design system utilities (bg-primary,text-danger, ...) and puts them first, among the unknown classes. - The local
@importof that entry must be relative (./base.css), otherwise the sorter can't resolve it. - One CSS entry per interface (admin, app, extranet, ...) → declare the main one as above and the others through
overrides:
// prettier.config.mjs
overrides: [
{ files: "assets/app/**", options: { tailwindStylesheet: "./assets/app/styles/main.css" } },
],- The
tailwindcsspackage must be resolvable: already the case on a project building its CSS, to add to the devDependencies of a repository only carrying the tooling.
Extra package to add:
npm install --save-dev @vue/eslint-config-typescript
This config, still the one of the create-vue TypeScript template, wires
typescript-eslint on the .vue files.
// eslint.config.mjs
// import ...
import { defineConfigWithVueTs, vueTsConfigs } from "@vue/eslint-config-typescript"
export default defineConfigWithVueTs(
{ name: "app/files-to-lint", files: ["**/*.{ts,mts,vue}"] },
// ...
vueTsConfigs.recommended,
// ...
)defineConfigWithVueTs replaces defineConfig: it is the wrapper propagating the TypeScript parser to the <script> blocks.
Type checking stays out of ESLint, and are handled by vue-tsc --build (the create-vue type-check script).
Both tools report a lot on a codebase that never had them. Rather than a big bang, each tool has its own way to only fail on new code.
- Bulk suppressions:
eslint-suppressions.jsoncounts existing violations per file and rule, only new ones fail. Same principle as the PHPStan baseline. npm run lint:baselineregenerates it from scratch (--suppress-allnever removes entries). Review its diff before committing.- Violation fixed → entry obsolete →
lint:checkexits with code 2 → regenerate. - Only
errorrules are recorded.
There is no proper "baseline" in Prettier but there is a similar alternative called pragma.
The requirePragma: true config option restricts it to files starting with a @format pragma
(options: "gradually transitioning large, unformatted codebases").
- Opt a file in:
npx prettier --write --no-require-pragma --insert-pragma <files>(--insert-pragmaalone does nothing,requirePragmawins). - Marker:
<!-- @format -->in.vue,/** @format */in.jsand.css. - Then
npm run lint:baseline: formatting fixesvue/first-attribute-linebreak, its entries become obsolete. - Remove the option once every file is formatted.
Formatting everything at once is the other option, and the only one that gets rid of the pragma right away. It suits a project with few front files, low sensitivity, and few people and open branches on it. That last point being the one to really assess, as every open branch will have to be rebased over the reformatted files.
- Commit the config, merge open branches.
- One commit with
npm run format, nothing else. - Safety net:
prettier --debug-checkon the same scope reports any file whose meaning might change. - Once merged (squash or rebase changes the hash), add the commit hash to
.git-blame-ignore-revs. Each dev runsgit config blame.ignoreRevsFile .git-blame-ignore-revs(PhpStorm follows it). GitLab: Blame preferences › Ignore specific revisions.
Caveats:
- Whole files only: no maintained tool formats changed lines only.
- Template edge case: text moved to its own line becomes an edge space once compiled by Vue. Invisible in a block, visible under
whitespace-pre-wrapor<pre>: check those areas. blame.ignoreRevsFileconfigured + file missing on a branch →git blamefails.
- No baseline, no pragma: both checks pass from the first commit,
validationstays green on every merge request.