How to keep a PHP site's Composer packages safe and up to date

Laravel, Symfony, Drupal and many other PHP sites are built from dozens of Composer packages. Here's how to find the ones with known security holes, update them safely and get them onto your server.

Never run composer update on the live server. Update on your own computer or a test copy, test the site, and commit composer.lock. The server only runs composer install, which installs exactly the versions you tested (step 6).

Before you start

  • Your project is in Git, with composer.json and composer.lock both committed. The lock file lists the exact version of every package.
  • Take a backup of the files and the database before you deploy updates. See our backup guide.
  • The examples use a Laravel site called shop in /var/www/shop, owned by the Linux user shop. Change the names to yours.
  • WordPress sites usually don't use Composer. For them, see our guide to hardening WordPress.

1. Check which Composer you have

composer --version

The version matters, because newer ones protect you better. Ubuntu 24.04 comes with Composer 2.7.1, Debian 13 with 2.8.8. The latest from getcomposer.org is 2.11.2 (November 2026). Since version 2.9, Composer refuses to install package versions with known security holes when you update (step 8). The Ubuntu and Debian versions don't do that yet.

To get the latest version, follow the four install commands on the Composer download page. Copy them from there: they include a checksum that changes with every release. Then move the file into place for all users:

sudo mv composer.phar /usr/local/bin/composer
hash -r
composer --version

Later, update Composer itself with sudo composer self-update. That's the one Composer command that needs sudo.

If Composer from apt is also installed, you now have two: /usr/local/bin/composer (new) and /usr/bin/composer (old). Your terminal finds the new one first, but cron jobs don't look in /usr/local/bin and quietly run the old one. That's why the cron job in step 5 uses the full path.

2. Run Composer as the site user, never as root

Packages can run their own code while Composer installs them, through plugins and scripts. That code runs with the rights of whoever started Composer. As root, it can change anything on the server. Composer warns you about this:

Do not run Composer as root/super user! See https://getcomposer.org/root for details

When you see that line, stop and run the command as the site's own user instead:

cd /var/www/shop
sudo -u shop composer install --no-dev --optimize-autoloader

Each site should have its own user, so a bad package can only reach its own site. Our guide to one PHP-FPM pool per site shows how to set that up.

3. Check for known security holes

Run this in the project folder:

composer audit

Composer compares your installed packages with a public list of known security problems, collected by Packagist.org from GitHub, the FriendsOfPHP database and others. It needs internet access to do that. A clean project says:

No security vulnerability advisories found.

In our test, we pinned a Laravel 13 app to an older symfony/http-foundation. Composer found it straight away (shortened):

Found 5 security vulnerability advisories affecting 1 package:
| Package           | symfony/http-foundation                    |
| Severity          | high                                       |
| CVE               | CVE-2026-108082                            |
| Title             | ReDoS in HeaderUtils::split() Reachable    |
|                   | Through the Cache-Control Request Header   |

Each entry has a link to the full advisory. It also lists abandoned packages: packages whose author stopped working on them. They won't get security fixes any more (step 9).

Useful options:

  • --locked checks composer.lock instead of the installed files. It works even before composer install has run.
  • --no-dev skips the development tools that you don't install on the server.
  • --format=summary prints only the count.

The exit code tells scripts the result. 0 means nothing was found. Anything else means Composer found a security hole or an abandoned package. The exact number depends on the version: 2.11 always uses 1, Debian 13's 2.8 uses 1, 2 or 3. So treat every number except 0 as "look at this".

Good to know: composer update and composer require run this check by themselves at the end. composer install doesn't, so a deploy needs its own check (step 6).

Want to see what's new? The security blog's front page lists recent high and critical GitHub advisories for Composer packages, updated daily.

4. See what's out of date

composer outdated --direct

This lists only the packages you chose yourself in composer.json. Each line has a mark:

  • ! a minor or patch update. It should be safe for your code, so update soon.
  • ~ a new major version. It can break your code, so read the package's upgrade notes first.

Your packages use other packages too, and those need updates as well. Plain composer outdated lists them all. In our test, --direct said "All your direct dependencies are up to date", while plain composer outdated showed the old symfony/http-foundation. composer audit always checks every package, so run it too.

5. Check every day with cron

New security holes are found in packages you already run. A daily check tells you about them. Open the site user's crontab:

sudo crontab -u shop -e

Add these two lines:

MAILTO=you@example.com
30 6 * * * cd /var/www/shop && out=$(/usr/local/bin/composer audit --locked --no-dev 2>&1) || echo "$out"

Every morning at 6:30 the job checks the lock file. When all is fine it prints nothing. When it finds something, it prints the full report, and cron emails that to MAILTO. Cron can only send email if your server is set up to send mail.

6. Deploy with install, not update

On the server, only install what's in composer.lock. Check the lock file first, so a deploy stops before it installs a known security hole:

cd /var/www/shop
sudo -u shop composer audit --locked --no-dev || exit 1
sudo -u shop composer install --no-dev --optimize-autoloader --no-interaction
  • --no-dev leaves out test and debug tools, such as PHPUnit. They don't belong on a live server.
  • --optimize-autoloader makes the site load its classes faster.
  • composer update on the server would pick new versions that you never tested, and make the server's lock file differ from the one in Git.

For the other Laravel deploy steps (migrations, caches, queue workers), see our guide to deploying Laravel on your own server.

7. Update one package at a time

Do this on your own computer or a test copy of the site, not on the live server. To update one package and the packages it needs:

composer update symfony/http-foundation --with-dependencies

--with-dependencies also updates the packages it needs, except the ones you listed in composer.json yourself. If Composer says one of those is in the way, use --with-all-dependencies instead.

Then check what changed, run your tests and check again:

git diff composer.lock
php artisan test
composer audit

Outside Laravel, run your own tests, for example vendor/bin/phpunit. Then click through the important pages: login, forms, checkout and the admin area. If all is well, commit both files together and deploy as in step 6:

git add composer.json composer.lock
git commit -m "Update symfony/http-foundation"

For a new major version, change the allowed version in composer.json, for example composer require vendor/package:^3.0, after reading the package's upgrade notes.

Something broke after a deploy? Go back to the previous commit and run composer install again. You get exactly the old versions back.

8. Let Composer block unsafe versions

Composer 2.9 and newer skip package versions with known security holes when you run update or require. It's on by default. In our test, asking Composer 2.11 for an old, unsafe version failed with:

found symfony/http-foundation[v8.1.6] but these were not loaded,
because they are affected by security advisories

Leave this on. If you really have to install an affected version for a while, the error message names the setting that lets you ignore one advisory. Write down why, and remove the exception when a fix is out. In older docs and projects the setting is called audit.block-insecure. Since Composer 2.10 it lives under policy.

This doesn't help on the server: composer install installs what the lock file says, even if a version is unsafe. That's why the deploy in step 6 runs composer audit first.

9. Replace abandoned packages

composer audit lists abandoned packages in their own table, often with a suggested replacement:

| Abandoned Package    | Suggested Replacement |
| doctrine/annotations | none                  |

An abandoned package still works, but nobody will fix its next security hole. Plan to move to the replacement, or to a package that does the same job.

Abandoned packages also make composer audit fail. Until you've replaced one, you can list it without failing, so your daily check and deploy keep working:

composer audit --abandoned=report

That option needs Composer 2.8 or newer. On Ubuntu 24.04's Composer 2.7, use COMPOSER_AUDIT_ABANDONED=report composer audit instead.

10. Keep vendor/ out of the web folder

The vendor folder holds the code of every package. Visitors should never be able to open it, and composer.json and composer.lock tell attackers exactly which versions you run.

Laravel and Symfony: point the web server at the public folder. vendor and the Composer files then sit one level above it, out of reach. If your own PHP project keeps vendor inside the web folder, move the web folder one level down in the same way if you can.

If you can't, block it. Nginx: create /etc/nginx/snippets/block-composer.conf:

# Composer's folder and files
location ^~ /vendor/ {
    deny all;
}
location ~ /composer\.(json|lock)$ {
    deny all;
}

Add include snippets/block-composer.conf; to the site's server { } block, then sudo nginx -t && sudo systemctl reload nginx. Apache: create /etc/apache2/conf-available/block-composer.conf:

# Composer's folder and files
RedirectMatch 404 ^/vendor/
RedirectMatch 404 /composer\.(json|lock)$
sudo a2enconf block-composer
sudo apache2ctl configtest && sudo systemctl reload apache2

Check: curl -s -o /dev/null -w '%{http_code}\n' https://example.com/composer.lock should print 403 (Nginx) or 404 (Apache). Our guide to blocking .env, .git and backup files adds the same kind of safety net for other private files.

11. Optional: let a bot open the pull requests

If your code is on GitHub, Dependabot can open a pull request whenever a package has an update. Add .github/dependabot.yml to the repository:

version: 2
updates:
  - package-ecosystem: "composer"
    directory: "/"
    schedule:
      interval: "weekly"

Renovate does the same and also works with GitLab, Bitbucket and others. Both only suggest the change. You still run the tests, merge and deploy with composer install.

If something goes wrong

  • "Do not run Composer as root/super user!": you're root. Run the command with sudo -u shop (step 2).
  • "these were not loaded, because they are affected by security advisories": the version you asked for, or the one another package needs, has a known security hole. Update that other package too, or try --with-all-dependencies.
  • "Your requirements could not be resolved": another package doesn't allow the version you asked for. Read the lines below the message. They name the package that's in the way.
  • The cron job reports an old Composer, or options it doesn't know: it runs /usr/bin/composer from apt. Use the full path /usr/local/bin/composer (step 1).
  • composer audit fails with a network error: the server can't reach packagist.org. Check the firewall's outgoing rules and DNS.
  • The site breaks after an update: deploy the previous commit and run composer install again (step 7).

Official sources

Next steps: make sure your PHP version still gets security fixes, and work through the security checklist for a new PHP server.

In the ServerNest preview, Composer updates take a restore point first. ServerNest checks the site afterwards, and you can undo an update that breaks it. The product is still in development. Join early access to try it first.

← Back to the security blog