• 6 min read

Laravel Vet Makes You Read the Code Composer Is About to Install

Vet is a Composer plugin that shows the diff of every dependency update before it lands in vendor, and records what you trust in a vet.json file.

Featured image for "Laravel Vet Makes You Read the Code Composer Is About to Install"

Every composer update writes thousands of lines into vendor/ that nobody on your team has read. We have all made peace with that. The usual defense is a lockfile, a vague sense that Packagist would notice something bad, and hope.

Laravel Vet, announced on September 15, takes a different position: you should read the code, or at least somebody should, and the tool should remember who did. It is a Composer plugin from the Laravel team that shows you the diff of every package change before Composer writes it, and records the packages you have accepted in a vet.json file next to composer.json.

If you have used cargo vet in the Rust world, this is the same shape. If you have not, the whole idea fits in one sentence: show me what changed, let me say I trust it, then only ask me again when it changes.

It works with any project that has a composer.json. Laravel, Symfony, WordPress, plain PHP. There is nothing Laravel-specific in what it does.

Installing it

Vet requires PHP 8.4 or later and installs as a dev dependency:

composer require laravel/vet --dev

Composer will ask whether to allow the plugin. Say yes, and it hooks itself in: it runs the audit after every composer install, and before composer update writes anything into vendor/.

You have no trust file yet, so the first run tells you so and exits non-zero:

WARN  No trust file yet. Run [vet --init] to record every package that
vendor/ holds today in [vet.json].

--init records everything currently on disk as trusted:

./vendor/bin/vet --init
INFO  Trusted [125] packages, and wrote [vet.json].

Be honest with yourself about what that means. --init trusts the bytes already sitting in your vendor/ directory. It does not review them. You are declaring a baseline, not auditing one, and everything Vet tells you from that point forward is a delta from a state nobody read. That is the only practical way to start on an existing project, but it is worth saying out loud.

It does refuse to paper over one gap. If composer.lock asks for a version that vendor/ does not hold yet, Vet will not quietly trust it:

to read first (2)

acme/logger 1.2.0 → 2.0.0 ........................ never trusted
acme/tooling 4.1.0 → 4.2.0 ....................... never trusted

ERROR  composer would write [2] packages that vendor/ does not hold.

What a review actually looks like

Once the baseline exists, an update that brings new code stops before it lands:

to review (1)

carbonphp/carbon-doctrine-types 3.1.0 → 3.2.0 ..... 2 files changed
│
│ runtime source (2)
│   ~ src/Carbon/Doctrine/DateTimeImmutableType.php
│     @@ -17,7 +17,7 @@
│     -    public function convertToPHPValue(...): ?DateTimeImmutable
│     +    public function convertToPHPValue(...): ?CarbonImmutable
│
Packages: 1 to review, 124 trusted

ERROR  [1] package is not trusted.

The detail I find genuinely useful is the bucketing. Vet sorts changed files into four categories and shows you the dangerous ones first:

BucketWhat it holds
install-time manifestThe package’s composer.json, which can add a script that runs at install time
opaque artifactBytes nobody can read, such as a .phar or a compiled library
runtime sourceCode your application autoloads and executes
inertTests, docs, images, everything else

That ordering encodes the actual threat model. A changed composer.json that adds a post-install-cmd is worse than a changed class, because it executes before you have looked at anything. A new .phar is worse than either, because there is nothing to read. Most diff tools treat a 4000-line test fixture and a one-line manifest change as equally worth your attention. This one does not.

Run ./vendor/bin/vet in a terminal and you get an interactive picker. Space to select, ctrl+a for everything, enter to record.

Handing the reading to an agent

Nobody is going to read twelve packages of diff by hand every Tuesday. Vet’s answer is to hand the changes to whatever coding agent is already on your machine. It looks for claude, then codex, then gemini on your PATH, and VET_AGENT_BINARY overrides that.

INFO  [claude] reviews [3] packages (58.1 KB). This takes a moment.

acme/logger 1.2.0 → 2.0.0 ......................... 12 files changed
│  FAIL   src/Ship.php reads .env and sends it to an unknown host

acme/tooling 4.1.0 → 4.2.0 ......................... 8 files changed
│  WARN   The agent did not read [1] file, because it is too big.
│         resources/schema.php  612.4 KB

carbonphp/carbon-doctrine-types 3.1.0 → 3.2.0 ...... 2 files changed
│  PASS   the changes narrow two return types

Four results: PASS (read everything, found nothing), FAIL (named a file and a reason), WARN (the reading is incomplete, usually a file too large for the prompt or a binary), and SKIP (nothing was sent).

The plumbing here is the part I would point a skeptical security person at. Vet turns the agent’s tools off and asks for a single JSON object back, so it reads and does nothing else. The changes sit inside a marker carrying a per-run token, and Vet checks every filename in the answer against the files it actually sent. It prints the count and total size of the prompts before the first one leaves your machine. The Composer plugin never invokes the agent on its own, and no agent result writes to vet.json until you answer the question yourself.

In the picker, Vet pre-selects the PASS rows and leaves FAIL and WARN unchecked. Press enter and the clean ones are recorded, which leaves you reading only the ones that need a human. That is the right default, and it is also the place where this becomes a rubber stamp if you let it. An agent that reports PASS on a subtly malicious package is worse than no review, because now there is a name in git history attached to it.

The trust file

vet.json is what makes this more than a fancy diff viewer. Commit it.

{
    "schema": 4,
    "require": {
        "carbonphp/carbon-doctrine-types": {
            "version": "3.2.1",
            "hash": "tree-v2:0f158f3b909fc01e691ed5f5121186056232b049031e7d3a914676d49881ece5"
        }
    },
    "require-dev": {
        "brianium/paratest": {
            "version": "v7.20.0",
            "hash": "tree-v2:075f8b7e73532ba3689126db0f91288a199bcba5f2743bc17a9bf37d53e030c1"
        }
    }
}

The hash covers every file in the package, not just the version string. If a package ships the same version number with different bytes, and that is exactly what a compromised maintainer account produces, the entry stops matching and Vet asks you to read the difference. A version pin alone would never catch that.

Because Vet exits non-zero on any untrusted package, and because the plugin runs automatically after composer install, your CI enforces this without a new pipeline step. A package nobody has read fails the build.

Where it fits

Vet is not the only thing happening in PHP supply chain security this year. Composer 2.10 shipped native malware blocking that refuses flagged versions from Packagist during both install and update, plus a config.policy block for advisories, malware, abandoned packages and custom blocklists. Heimdall enforces a minimum age on dependencies so a fresh release has to sit for a few days before you install it.

Those are different layers. Composer 2.10 blocks what is already known to be bad. Heimdall buys time for somebody else to find out. Vet is the only one that asks whether anyone on your team has looked.

One caveat worth repeating: Vet is in beta, has no tagged release yet, and the README carries an explicit warning that behavior can change. Put it on a side project first. But the idea is sound, and the design choices around the agent integration suggest people thought carefully about what could go wrong.


Sources