Renovate for Laravel: Automated Composer and npm Updates That Do Not Drown You in PRs

Configure Renovate on a Laravel repo: grouped Composer and npm PRs, a weekly schedule, tiered automerge, and packageRules that keep laravel/framework manual.

Steven Richardson
Steven Richardson
· 15 min read

Two failure modes, same repository. Either composer.lock has not moved since 2024 and the next composer update produces a 2,000-line diff nobody wants to own, or somebody installed Renovate on Friday and by Monday there are thirty-one open pull requests, each bumping one patch version.

The second one is not the bot misbehaving. Renovate's defaults are deliberately conservative: one dependency per branch, so you can merge or reject each in isolation. That is the right default for a library with three dependencies and exactly wrong for a Laravel app with ninety. The configuration that makes it usable is about forty lines of packageRules, and almost nobody writes it down for a PHP codebase.

This is that configuration, built up in stages, plus the Laravel-specific rules — framework majors, first-party packages, private Composer registries, platform constraints — that the generic guides skip.

Decide between Renovate and Dependabot#

Pick the tool before you write any config, because the answer is genuinely not always Renovate. Dependabot is built into GitHub, needs no app install, and has closed most of the feature gap: it groups dependencies, splits by dependency-type: development or production, and takes a schedule. If all you want is security PRs on a small repository, enable Dependabot alerts and stop reading.

Renovate Dependabot
Composer support Yes (composer manager) Yes
Install GitHub App or self-hosted Built into GitHub
Config renovate.json, ~250 options .github/dependabot.yml, ~20 options
Grouping packageRules + groupName, any matcher groups by dependency-type and name patterns
Scheduling Cron syntax, per-rule Daily/weekly/monthly, per-ecosystem
Automerge Native, per-rule, tiered None — you write a GitHub Actions workflow
Lockfile-only updates rangeStrategy: update-lockfile No equivalent
Refresh whole lockfile lockFileMaintenance No equivalent
Dependency dashboard Yes, a tracking issue No
Private Composer registries hostRules + encrypted GitHub secrets

The deciding factors are automerge and lockFileMaintenance. Dependabot has no automerge of its own — the documented pattern is a workflow that calls gh pr merge --auto on PRs authored by dependabot[bot], which works but puts your merge policy in a YAML file separate from your update policy. And nothing in Dependabot refreshes transitive dependencies that are already inside your declared ranges, which is where most rot actually lives.

I use Renovate on anything with a deploy pipeline and Dependabot on repositories I check twice a year.

Install Renovate on the repository#

Install the Mend-hosted Renovate GitHub App and grant it access to the repository. It takes about five minutes and needs no infrastructure. Renovate scans the repo, finds composer.json and package.json, and opens an onboarding PR containing a starter renovate.json.

Self-host instead when you need Renovate to reach a private Composer registry — Private Packagist, a Satis instance, or a paid repository like Filament or Flux. The hosted app can read those through encrypted hostRules, but if your security posture forbids handing over a registry token at all, run it yourself:

# .github/workflows/renovate.yml
name: Renovate

on:
  schedule:
    - cron: '0 3 * * 1' # 03:00 UTC Monday
  workflow_dispatch:

jobs:
  renovate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/create-github-app-token@v2
        id: app-token
        with:
          app-id: ${{ vars.RENOVATE_APP_ID }}
          private-key: ${{ secrets.RENOVATE_PRIVATE_KEY }}

      - uses: renovatebot/github-action@v46.3.1
        env:
          # Composer reads this directly when resolving private packages.
          COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}
        with:
          token: ${{ steps.app-token.outputs.token }}
          configurationFile: renovate.json

COMPOSER_AUTH is the JSON blob Composer already understands, so it is the same value your deploy pipeline uses:

{
  "http-basic": {
    "repo.packagist.com": { "username": "token", "password": "xxxxx" },
    "composer.fluxui.dev": { "username": "you@example.com", "password": "xxxxx" }
  }
}

Pin the action to a full version rather than a floating major — and then let Renovate update that pin for you, which is a pleasing way to prove the setup works.

Read the onboarding PR without merging it#

Renovate's first PR proposes {"extends": ["config:recommended"]} and lists every update it would open. Do not merge it as written. config:recommended is a sensible base — it turns on the dependency dashboard, groups known monorepos, and applies the maintained workaround presets — but it does not group anything specific to your project and it does not schedule.

Read the "What to Expect" table in that PR body instead. It is the only preview you get of how many PRs are coming. If it lists thirty-one rows, you now know exactly what merging the default config does.

Then look at the Dependency Dashboard issue, which is the best thing in the product and the one most people never open. It lists every detected update including the ones Renovate is rate-limited out of creating, every branch it has open, and every dependency it failed to look up. When Renovate seems to be doing nothing, the dashboard is where the reason is.

Add a schedule and concurrency limits#

Two of Renovate's defaults create the flood: prConcurrentLimit is 10 and prHourlyLimit is 2. So the bot trickles out two PRs an hour until ten are open, then stops — which feels like a flood spread over a working day and reads as a bot out of control.

Set a window instead. The schedule:weekly preset expands to schedule:earlyMondays, which is the cron expression * 0-3 * * 1 — before 4am Monday:

{
  "$schema": "https://docs.renovatebot.com/renovate-schema.json",
  "extends": ["config:recommended", "schedule:weekly"],
  "timezone": "Europe/London",
  "prConcurrentLimit": 5,
  "prHourlyLimit": 0
}

timezone must be an IANA name, and without it the cron window is UTC — in British Summer Time that silently shifts your "before 4am" to 1am–4am. Setting prHourlyLimit to 0 removes the rate limit, which is what you want once a schedule exists: the PRs should all land in the same window, not dribble through the morning. Renovate's own advice is to allow at least a three to four hour window, because it only runs when your hosted instance gets to you.

prConcurrentLimit also feeds branchConcurrentLimit when that is left unset, so five open PRs means five open branches. Security PRs ignore the limit entirely and always get created.

Group the updates with packageRules#

This is the part that matters. packageRules is an array where every rule is evaluated, matching rules merge together, and later rules override earlier ones — so order runs least important at the top, most important at the bottom. Within a single rule, different matchers are ANDed; multiple patterns inside one matcher are ORed.

Composer exposes two dep types, require and require-dev. npm exposes dependencies and devDependencies. Four groups cover a Laravel app:

{
  "packageRules": [
    {
      "description": "Dev tooling: one PR, everything non-major",
      "matchDepTypes": ["require-dev", "devDependencies"],
      "matchUpdateTypes": ["minor", "patch", "digest"],
      "groupName": "dev dependencies",
      "groupSlug": "dev-deps"
    },
    {
      "description": "Runtime deps: one PR, non-major, reviewed",
      "matchDepTypes": ["require", "dependencies"],
      "matchUpdateTypes": ["minor", "patch"],
      "groupName": "production dependencies",
      "groupSlug": "prod-deps"
    },
    {
      "description": "Majors always stand alone",
      "matchUpdateTypes": ["major"],
      "automerge": false,
      "addLabels": ["dependencies:major"]
    }
  ]
}

Two traps here. separateMajorMinor defaults to true and takes priority over grouping, so majors will split out of a group whether or not you write the third rule — write it anyway, for the label. And use addLabels rather than labels: labels is non-mergeable, so when two rules match, the last one wins and silently discards the first rule's labels.

matchPackagePatterns no longer appears in the docs. matchPackageNames now handles exact names, glob patterns and regex — anything starting with / or !/ is treated as a regex, anything else is matched with minimatch, case-insensitively.

Add the Laravel-specific package rules#

Generic guides stop at the previous step. These five rules are what make Renovate behave sensibly on a Laravel codebase, and they go at the bottom of the array so they override the grouping above.

{
  "packageRules": [
    {
      "description": "The framework gets its own PR and a link to the upgrade guide",
      "matchPackageNames": ["laravel/framework"],
      "groupName": null,
      "automerge": false,
      "addLabels": ["framework"],
      "prBodyNotes": [
        "{{#if isMajor}}Read https://laravel.com/docs/master/upgrade before merging.{{/if}}"
      ]
    },
    {
      "description": "First-party packages move together with the framework",
      "matchPackageNames": [
        "laravel/**",
        "livewire/**",
        "filament/**",
        "!laravel/framework"
      ],
      "matchUpdateTypes": ["minor", "patch"],
      "groupName": "laravel ecosystem"
    },
    {
      "description": "Never propose a PHP the production runtime cannot run",
      "matchDepTypes": ["require"],
      "matchPackageNames": ["php"],
      "enabled": false
    },
    {
      "description": "Refresh the whole lockfile monthly",
      "matchUpdateTypes": ["lockFileMaintenance"],
      "addLabels": ["lockfile"]
    }
  ],
  "lockFileMaintenance": {
    "enabled": true,
    "schedule": ["* 0-3 1 * *"]
  }
}

laravel/framework majors always coincide with a documented upgrade guide, and prBodyNotes puts the link in the PR body conditionally — the {{#if isMajor}} handlebars block only renders on majors. In practice a framework major is a planned piece of work, not a merge; the value of the PR is that it tells you the moment the release lands. When you get to it, the Laravel 12 to 13 upgrade guide covers what actually breaks, and Rector's prepared sets handle most of the mechanical half.

First-party packages are grouped because they move together. A Livewire minor and a Filament minor released the same week are usually the same coordinated change, and reviewing them in one PR is how you notice. Paid repositories belong here too — a Filament v4 to v5 jump is a project, and you want it isolated the same way a framework major is.

lockFileMaintenance is disabled by default and defaults to "before 4am on monday" when you enable it. Weekly is too often for a refresh that deletes composer.lock and regenerates it from scratch — the cron * 0-3 1 * * moves it to the first of the month. Turn this on last, after you trust the test suite, because it is the single most likely source of a surprise break.

Set up CI before you enable automerge#

Automerge is only as safe as the checks it waits for, so this step comes before the automerge rules, not after. Renovate will not merge until it sees passing status checks — unless you set ignoreTests: true, which you should not. The gate needs three things running on renovate/** branches: the full test suite, static analysis, and a lint pass.

# .github/workflows/ci.yml
name: CI

on:
  pull_request:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v5

      - uses: shivammathur/setup-php@v2
        with:
          php-version: '8.4'
          coverage: pcov

      - uses: ramsey/composer-install@v3

      - run: vendor/bin/pint --test
      - run: vendor/bin/phpstan analyse --no-progress
      - run: php artisan test --parallel

Then make those checks required. In branch protection for main, enable "Require status checks to pass before merging" and select the job by name — if you require no specific check and rely on platform automerge, GitHub will happily merge a PR with failing tests.

Composer and npm installs dominate the runtime here, so caching them properly is what keeps a grouped weekly PR from taking twenty minutes to go green. If the static analysis step is new, running Pint, PHPStan and Rector through Duster collapses the three commands into one, and Pest architecture tests are the cheapest way to catch the structural breakage a dependency bump causes that unit tests miss.

Enable automerge in tiers#

With the gate in place, automerge in tiers by blast radius. Dev tooling first, because it is both the safest and the noisiest — pestphp/pest, larastan/larastan, laravel/pint, rector/rector and the npm build chain account for most of the PR volume in a typical Laravel repo and none of them ship to production.

{
  "packageRules": [
    {
      "description": "Tier 1 — dev tooling, non-major, automerged",
      "matchDepTypes": ["require-dev", "devDependencies"],
      "matchUpdateTypes": ["minor", "patch", "digest"],
      "matchCurrentVersion": "!/^0/",
      "groupName": "dev dependencies",
      "automerge": true
    },
    {
      "description": "Tier 2 — runtime patches, automerged",
      "matchDepTypes": ["require", "dependencies"],
      "matchUpdateTypes": ["patch"],
      "matchCurrentVersion": "!/^0/",
      "automerge": true
    },
    {
      "description": "Tier 3 — runtime minors, reviewed by a human",
      "matchDepTypes": ["require", "dependencies"],
      "matchUpdateTypes": ["minor"],
      "automerge": false
    }
  ]
}

matchCurrentVersion: "!/^0/" excludes anything below 1.0.0, because SemVer permits breaking changes in any 0.x release including patches — a 0.4.1 to 0.4.2 bump carries no guarantee at all.

Leave automergeType at its default of "pr". The docs suggest "branch" for silent merges, but branch automerge pushes straight to the base branch, so it stops working the moment branch protection requires pull requests — which on any repository worth automating, it does.

platformAutomerge defaults to true and hands the merge to GitHub's own auto-merge, which is faster. The catch: with platform automerge on, Renovate enqueues the merge at PR creation time, so an automergeSchedule cannot be honoured. If you need merges confined to a window, set platformAutomerge: false and let Renovate do the merging.

Triage a Renovate PR in three seconds#

The whole point of the tiering is that most PRs need no thought, so have a fixed routine for the ones that reach you. Three questions, in order: is CI green, is it a runtime dependency, and does the rendered changelog mention behaviour rather than internals.

Renovate embeds the release notes and a compare link in the PR body, which is enough for the majority. A green grouped dev-dependency PR gets merged unread. A runtime minor gets thirty seconds on the changelog. Anything with a failing check gets opened properly, because a dependency bump that fails your suite has told you something true.

For drift you can see without opening anything, add a non-blocking step to CI:

      - name: Report outdated direct dependencies
        run: composer outdated --direct --strict || true
        continue-on-error: true

--direct limits it to packages in your composer.json rather than the full transitive tree, so the output stays readable. It keeps drift visible even in the weeks the bot is paused.

Onboard a repository that is already two years behind#

Do not point Renovate at a stale repository and let it run. It will queue every update at once, discover that half of them conflict, and produce a dashboard you will never work through.

Sequence it manually first. Update laravel/framework and the first-party packages by hand and get CI green — that is the change most likely to need code edits, and doing it in isolation keeps the diff readable. Then run composer update for everything else in one pass and fix the fallout. Then, before you install the bot, prune the packages you are no longer using, because carrying dead dependencies through automated updates forever is the definition of wasted CI minutes.

Only once composer outdated --direct is close to empty should you install Renovate. It exists to hold a line, not to fight back to one.

Gotchas and Edge Cases#

Renovate proposes nothing because your constraint forbids it. A composer.json pinned to "spatie/laravel-permission": "^5.0" when the package is on 6.x produces a suspiciously quiet bot. Renovate respects your declared range by default. rangeStrategy defaults to "auto" and resolves per-manager; set "widen" on a rule to get ^5.0 || ^6.0 proposed, or check the dashboard, which lists these as available-but-blocked.

Automerge silently never fires because a review is required. If branch protection requires approving reviews, the bot cannot approve its own PR, so the merge just never happens and nothing explains why. On github.com, add Renovate to "Allow specified actors to bypass required pull requests" in the branch protection rule. On the Mend-hosted app you can instead install the renovate-approve helper app, which marks automerging PRs as approved. A CODEOWNERS file causes the same silent stall.

One failing package blocks the entire group. A grouped PR is atomic — if larastan needs a code change, the other eleven updates in that branch wait for it. Either push the fix onto the Renovate branch directly, or set separateMinorPatch: true (it defaults to false) so patches split into their own lower-risk branch and keep flowing.

A private registry produces "package not found" rather than an auth error. Missing credentials look identical to a missing package in Renovate's logs. Add a hostRules entry with matchHost set to the registry host and hostType: "packagist", and encrypt the token at app.renovatebot.com/encrypt — never commit a raw token to renovate.json.

The PHP platform mismatch that makes a lockfile unmergeable. Renovate runs Composer with --ignore-platform-req for extensions and libraries by default, because its own container will not match your production PHP. Where this bites is a config.platform.php in composer.json that is older than the version CI runs: the lockfile Renovate generates is valid for the platform override and rejected by your pipeline. Keep the platform override, CI's PHP and production's PHP on the same version. A PHP matrix in CI surfaces this the first time it happens rather than on the tenth PR.

Automerge takes longer than you expect. Renovate merges at most one branch per target branch per run, because merging one branch invalidates the git state of the others. Give it a couple of hours before deciding it is broken.

Wrapping Up#

Start with the schedule, the two dep-type groups, and a laravel/framework rule — that alone takes a repository from thirty PRs to about three. Add automerge only once required status checks are enforced on main, and turn on lockFileMaintenance last.

The prerequisite is a suite you would bet a deploy on. If yours is not there yet, Pest architecture tests and PHPStan at level 10 are the two cheapest things you can add that make an automerged dependency bump genuinely safe.

FAQ#

How do I set up Renovate for a Laravel project?

Install the Mend-hosted Renovate GitHub App on the repository and let it open its onboarding pull request. Rather than merging that PR as written, replace the proposed config with one that extends config:recommended, sets a schedule and timezone, and defines packageRules grouping require-dev and devDependencies into one branch and require and dependencies into another. Self-host via the renovatebot/github-action workflow instead if you need Renovate to authenticate against a private Composer registry.

What is the difference between Renovate and Dependabot for PHP?

Both support Composer and both can now group updates and run on a schedule. The differences that matter are automerge, which Renovate handles natively per package rule while Dependabot requires a separate GitHub Actions workflow calling gh pr merge --auto, and lockfile handling — Renovate offers rangeStrategy: update-lockfile and lockFileMaintenance to refresh transitive dependencies, which Dependabot has no equivalent for. Dependabot is the better choice on a small repository where you only want security PRs and no infrastructure decisions.

How do I stop Renovate from opening too many pull requests?

Three settings, in order of effect. Add a schedule such as the schedule:weekly preset so updates arrive in one window instead of continuously. Add packageRules with groupName so all non-major dev dependencies share a single branch and PR. Then set prConcurrentLimit to cap how many stay open at once — it defaults to 10, which is where the flood comes from. Grouping is what does most of the work; the limit is a safety net.

Is it safe to automerge dependency updates in Laravel?

It is safe exactly to the degree your CI is trustworthy, because Renovate merges on green status checks and nothing else. Require the full test suite, a static analysis pass and a lint check on pull requests, and enforce them in branch protection. Then automerge non-major dev dependencies first, runtime patches second, and never automerge a major or laravel/framework. Automerging without required checks configured, or with ignoreTests set to true, is just an automated way to ship a regression.

How do I group Composer and npm updates into one PR?

Use a single packageRules entry with matchDepTypes listing the dep types from both managers and a shared groupName. Composer exposes require and require-dev; npm exposes dependencies and devDependencies. A rule matching ["require-dev", "devDependencies"] with matchUpdateTypes of minor and patch puts every non-major development update from both ecosystems into one branch. Note that separateMajorMinor defaults to true and overrides grouping, so majors will still split into their own PRs.

Why is Renovate not proposing an update I know exists?

Almost always because the constraint in composer.json forbids it. Renovate respects your declared range, so a ^5.0 caret on a package that has released 6.x produces no PR at all. Open the Dependency Dashboard issue — it lists updates that exist but are blocked, along with lookup failures and rate-limited branches. If the constraint is the cause, set rangeStrategy to "widen" on a package rule for that dependency, or widen the constraint by hand.

How do I keep Renovate from bumping laravel/framework automatically?

Add a packageRules entry matching laravel/framework with automerge set to false and groupName set to null, which pulls it out of any group it would otherwise land in and gives it a PR of its own. Add a prBodyNotes entry wrapped in a {{#if isMajor}} handlebars block to render a link to the Laravel upgrade guide in the PR body on majors only. Because separateMajorMinor defaults to true, framework majors already get their own branch — the rule is what stops a framework minor sliding into an automerged group.

Steven Richardson
Steven Richardson

CTO at Digitonic. Writing about Laravel, architecture, and the craft of leading software teams from the west coast of Scotland.