Skip to content
Auditing an inherited Laravel application on day oneBackend
Backend

Inheriting a Laravel App: The Seven Things We Open on Day One

RRRavi Rai··19 min read

The handover call lasts twenty minutes. The developer who built the thing shares a screen, points at a folder on the server, says the deploy script is in there somewhere, and drops off. Nobody writes anything down. Three weeks later a payment stops reconciling, and you are the one who has to find out why.

Most people who go looking to hire a Laravel developer are not starting anything. They have an application, it works more or less, and the person who knows how it works is leaving. They ask us whether the code is any good, and nobody can answer that honestly in a day. The question we can answer in a day is a different one: how much of what this application knows is written down anywhere other than in one person's head.

So we run a fixed sequence, ordered by what becomes unrecoverable rather than by what is interesting. Code is the cheapest thing in a handover, because code cannot leave. Accounts, servers and one person's memory can, so those come first. Then seven things in the code and on the server, cheapest signal first. The terminal output below is representative, assembled from the shapes we keep seeing, not a transcript from any one client.

Before day one: the six things that can leave

We ask for six things in writing, because the state of this list is itself the first finding. An owner who cannot produce them does not have a handover problem, they have a control problem, and that is more urgent than anything in the code.

  • The git repository with its full history, not a zip of the current files. A zip is a finding on its own.
  • A read-only database user on production, because we are about to query a live system.
  • The .env file, or a copy with values redacted and key names intact. The key names alone tell us most of what we need.
  • Who owns the domain, the DNS and the server billing account. Not who manages them, who owns them. Three accounts, often held by three different people, one of whom is leaving.
  • A register of every third party account with the email address that owns it. The failure we see most is not a missing password. It is two-factor authentication on the outgoing developer's phone: the password is in the handover document and the one-time code is on a handset in another city. While they are still engaged that is one screen share. Afterwards it is a support ticket asking for proof of ownership you cannot produce.
  • The most recent database backup, and whether anyone has ever restored it. The second question matters more. We restore it into a scratch database on day one, time it, and write the number down.

If all six arrive within an hour, the rest of the day usually goes well. That correlation is not a coincidence.

1. composer.json, and the version you are actually on

First file open, always, because it is free and it constrains everything after it. We are reading it for the gap between what is installed and what is still supported.

bash$ php artisan --version
Laravel Framework 8.83.27

$ php -v
PHP 7.4.33 (cli) (built: Nov  2 2022 13:37:07) ( NTS )

$ composer show --direct --outdated --ignore-platform-reqs
barryvdh/laravel-dompdf    v0.9.0    v3.1.0
doctrine/dbal              2.13.9    4.2.1
spatie/laravel-permission  4.4.0     6.16.0

# Without --ignore-platform-reqs the latest column only shows versions
# the current PHP can run, which on 7.4 hides the two-major gap.

$ composer audit
Found 4 security vulnerability advisories affecting 3 packages.

# Name the package standing between you and a supported version.
# Substitute the current major for 12.*; on PHP 7.4 expect a long list.
$ composer why-not laravel/framework 12.*
$ composer why-not php 8.3

Three numbers matter and none of them is the dependency count. The Laravel major version says how far the upgrade path runs. The PHP version says whether the runtime itself is out of security support, which is usually the worse problem. composer audit says whether anything installed has a published advisory. composer why-not turns "we are stuck" into the name of the package doing the sticking. And if composer.lock is not committed, nobody can reproduce the running application.

  • Good: current or one major behind, PHP still supported, audit clean. The upgrade is scheduled work, not an emergency.
  • Quote-changing: an abandoned direct dependency with no replacement, sitting on the critical path. Somebody has to rewrite that feature.
  • Ask your developer: which versions of Laravel and PHP are we on, are they still getting security updates, and what is stopping us moving up?

2. Migrations against the live schema

This is the step that catches most rebuild candidates, and the one people skip because it needs production access rather than a code read. The migrations describe a database. Production contains a different one. The gap between them is the most useful number we get all day.

bash# On production, read-only
$ php artisan migrate:status | tail -4
| Yes  | 2023_06_02_084510_create_payout_batches_table | 44   |
| No   | 2024_01_19_113002_add_index_to_sessions_table  |      |
| No   | 2024_03_04_090011_drop_legacy_wallet_table     |      |

# Locally: build a database from the migrations alone.
# The shell variable wins over .env, so no config change is needed.
$ createdb audit_fresh
$ DB_DATABASE=audit_fresh php artisan migrate --force

# Dump both schemas without data and compare the table lists
$ pg_dump --schema-only --no-owner -d audit_fresh    > /tmp/from-migrations.sql
$ pg_dump --schema-only --no-owner -h prod -U ro app > /tmp/from-production.sql

$ diff <(grep '^CREATE TABLE' /tmp/from-migrations.sql | sort) \
       <(grep '^CREATE TABLE' /tmp/from-production.sql | sort)
> CREATE TABLE public.invoice_adjustments (
> CREATE TABLE public.legacy_wallet (
> CREATE TABLE public.settlement_overrides (

# MySQL: same idea, with the counters stripped so they do not pollute the diff
$ mysqldump --no-data --skip-comments -h prod -u ro -p app \
    | sed 's/ AUTO_INCREMENT=[0-9]*//' > /tmp/from-production.sql

Two of those tables, invoice_adjustments and settlement_overrides, exist in production and no migration creates them. Somebody made them by hand, through a GUI or a psql session, and the reason is recorded nowhere. The third, legacy_wallet, is the opposite failure: a migration was written to drop it and never ran, so the code says the table is gone and production says it is not. Each one is a decision nobody wrote down.

  • Good: a database built from migrations alone matches production table for table and column for column.
  • Quote-changing: no migrations table at all, or a migrations directory squashed into one file that will not run against an existing database. The schema is now the specification.
  • Ask your developer: given an empty database and this repository, how long until you have a working local copy, and when did you last try?

3. The tests, if there are any

We run the suite before reading a line of it, because what we want first is whether it passes on a clean checkout. A suite nobody runs is not a safety net. It is documentation that went stale without telling you.

bash$ php artisan test

   PASS  Tests\Unit\ExampleTest
  - that true is true

   PASS  Tests\Feature\InvoiceTest
  - invoice pdf renders
  - invoice totals include gst

   FAIL  Tests\Feature\PayoutTest
  x payout batch settles
  SQLSTATE[42P01]: Undefined table: 7 ERROR: relation "settlement_overrides" does not exist

  Tests:  1 failed, 3 passed
  Time:   1.92s

# How many assertions actually exist, by file
$ grep -rc 'assert' tests/ | sort -t: -k2 -rn | head -4
tests/Feature/InvoiceTest.php:9
tests/Feature/PayoutTest.php:2
tests/Unit/ExampleTest.php:1
tests/Feature/AuthTest.php:0

# Does the suite reach anything outside this machine?
$ grep -rEn 'https?://' tests/ | grep -vE 'localhost|127\.0\.0\.1|example\.(com|test)'
tests/Feature/PayoutTest.php:31:  $res = Http::post('https://api.razorpay.com/v1/payouts', ...

Eleven assertions for a whole billing application. The failure is also the missing table from step two, which is the point of the ordering: the second signal confirms the first instead of arriving as a surprise a fortnight in.

  • Good: green on a clean checkout with one command, a throwaway database, no live third party touched.
  • Fine, honestly: no tests, and everyone says so plainly. We can price adding them around the parts we are about to change. Zero is an honest number.
  • Stop and talk: the suite posts to a live payment or messaging API. Running it costs real money or messages real customers, which is why nobody runs it.
  • Ask your developer: are there automated tests, how do I run them, and when did they last pass? A vague answer is a worse sign than a zero.

4. The queue and the scheduler

Everything that happens without a person clicking something lives here, and it is the part most likely to run on an arrangement nobody documented.

bash# Laravel 8 here, so we read the file. On Laravel 9 and later,
# `php artisan about` prints the running queue, cache and session
# drivers even when config is cached, and is the stronger check.
$ grep -E '^(QUEUE_CONNECTION|CACHE_DRIVER|SESSION_DRIVER)' .env
QUEUE_CONNECTION=sync
CACHE_DRIVER=file
SESSION_DRIVER=file

$ php artisan schedule:list
| php artisan invoices:generate  | 0 2 * * *   | 2026-09-19 02:00:00 |
| php artisan payments:reconcile | */5 * * * * | 2026-09-18 14:35:00 |

$ sudo grep -rl 'schedule:run' /etc/crontab /etc/cron.d /var/spool/cron 2>/dev/null
(no output)

# Nothing on this machine invokes the scheduler. Check the deploy user too:
$ sudo crontab -l -u www-data
no crontab for www-data

$ sudo supervisorctl status
horizon    STOPPED   Sep 02 04:11 AM

QUEUE_CONNECTION=sync in production means there is no queue. Laravel 10 and earlier shipped sync as the default and inherited applications carry it forward. Every job marked as queued runs inline inside the request that dispatched it, so a slow PDF is a slow page and a failed job is a failed page with no retry and no record. The failed_jobs table is empty for the least reassuring reason available.

The scheduler has its own trap. schedule:list happily prints tasks on a server where nothing ever invokes schedule:run. The list is what the code intends. The crontab is what the machine does. A nightly job can go unrun for months while the code defining it sits in the repository looking correct, and nothing in the repository will tell you. On shared hosting there may be no supervisor and no www-data crontab at all, and silence from those commands is not a clean result.

sql-- What has been failing, and since when
SELECT queue,
       count(*)       AS failures,
       min(failed_at) AS first_seen,
       max(failed_at) AS last_seen
FROM failed_jobs
GROUP BY queue
ORDER BY failures DESC;

-- Jobs sitting in the queue right now, oldest first.
-- Database driver only: on redis or Horizon ask `php artisan horizon:status`
-- and the Horizon dashboard instead. available_at is an integer epoch,
-- hence to_timestamp.
SELECT queue,
       count(*)                        AS waiting,
       min(to_timestamp(available_at)) AS oldest
FROM jobs
GROUP BY queue;

The second query is grouped by queue for a reason. A worker consuming only the default queue while jobs are dispatched to an invoices queue looks healthy in supervisorctl and processes nothing. The other quiet failure is a worker somebody started by hand: it holds whatever code was on disk when it started, for as long as it lives, which is what queue:restart on every deploy is for.

  • Good: a real driver, something that restarts workers, a cron entry for schedule:run, and a failed_jobs table that is empty or actively drained.
  • Quote-changing: sync in production on an application that moves money or sends messages. The failure mode is silent loss, and the fix touches every dispatch site, because jobs written under sync never had to be safe to run twice.
  • Ask your developer: what runs on a schedule, what happens if it does not run, and when a customer pays, is any of the work still running while they wait?

On PlugEV, which we run on Laravel 11 and Postgres, this is the layer we are strictest about, because a car charging in the middle of the night has to bill correctly with nobody watching. On an inherited application the question is whether a retry is safe, and usually nobody has ever tested it.

5. The .env file, and what is hardcoded

We read the key names first and the values second. The key names are a list of every external system this application depends on, usually longer than the one we were given on the handover call. Then we look for the two mismatches that matter: things configured but never read, and things read but never configured.

bash# Keys set in .env that no code ever reads (column 1 only, hence -23)
$ comm -23 \
    <(grep -oE '^[A-Z][A-Z0-9_]+' .env | sort -u) \
    <(grep -rhoE "env\([\"'][A-Z0-9_]+[\"']" app config routes | grep -oE '[A-Z0-9_]+' | sort -u)
OLD_SMS_GATEWAY_KEY
PAYU_MERCHANT_SALT

# env() called outside config/, which returns null once config is cached
$ grep -rn 'env(' app/ routes/ | wc -l
23

# Anything live-looking ever committed, across the whole history
$ git grep -nE '(sk_live_|rzp_live_|AKIA[0-9A-Z]{16}|BEGIN RSA PRIVATE KEY)' \
    $(git rev-list --all) -- . | head

That last command is slow on a large history and worth the wait exactly once. A key committed and later removed is still in the history, still valid unless somebody rotated it, and still readable by everyone who ever cloned the repository. Rotating it is day one work, and it happens before access is revoked, not after.

The env() count is the quieter finding. Laravel returns null from env() outside config files once the configuration is cached, so those twenty three calls behave differently depending on whether somebody ran config:cache on the last deploy. That is a bug that appears only in production and only sometimes, and it is one we have shipped ourselves.

  • Bad: account numbers, tax rates or business thresholds hardcoded in PHP. These change by law rather than by choice.
  • Quote-changing: live credentials in the history that nobody can rotate, because nobody knows which vendor account they belong to.

6. The vendor directory and the packages somebody patched

The most expensive thing we ever find is a package edited in place. It works, it is invisible, it survives no composer update, and the person who did it has just left. The check takes two minutes, which is the whole argument for never skipping it.

bash# Is vendor/ committed? If so, the patches are checkable directly.
$ git ls-files vendor/ | wc -l
18422

# Reinstall from the lock file and see what the repository disagrees with.
# vendor/composer is excluded: the autoloader churns between Composer
# patch versions and would bury the two lines that matter.
$ rm -rf vendor && composer install --no-scripts
$ git status --porcelain -- vendor/ ':(exclude)vendor/composer'
 M vendor/barryvdh/laravel-dompdf/src/PDF.php
 M vendor/spatie/laravel-permission/src/Middlewares/RoleMiddleware.php

# If vendor is not committed: look for a patching mechanism instead
$ composer validate --no-check-publish
$ grep -A6 '"repositories"' composer.json
$ grep -nE '"dev-master"|"dev-main"' composer.json

Two patched packages, and one is a permission middleware. The authorisation rules of this application are not where anybody would look for them, The authorisation rules of this application are not where anybody would look for them. Our piece on Laravel admin dashboard architecture lays out the four layers authorisation is meant to live in, and a hand-patched vendor middleware is in none of them, which means nobody is reviewing it.

  • Good: vendor not committed, composer.lock committed, a clean install reproducing the deployed tree, no patching mechanism.
  • Bad: hand-edited vendor files. Each is an undocumented fork that the next update silently reverts, usually months later, usually in production.
  • Quote-changing: a repositories block pointing at a private fork on an account we cannot reach. Nobody can install the application from scratch, so nobody can move it, so nobody can recover it if this server dies.

7. The git history, last

The history is the most interesting thing in the repository, and that is exactly why we read it last. A story will happily consume half a day while the schema drift that actually sets the price sits unexamined. By now we know what we are quoting. The history only tells us how confident to be.

bash$ git log --oneline | wc -l
1

$ git log --format='%h %ad %an %s' --date=short
a41f0c2 2026-08-29 Vendor Handover  initial commit

# On a healthier repository, the questions are different
$ git log --format='%an' | sort | uniq -c | sort -rn | head
   1841 dev-1
    612 deploy-bot
     28 root

$ git log --format='%ad' --date=format:'%Y-%m' | uniq -c | tail -8
$ git log --diff-filter=D --name-only --format='%h %ad' -- 'tests/*' | head

One commit, dated three weeks ago, authored by an account created for the handover. Somebody copied a working directory into a fresh repository, so every decision made across the life of this application is unrecoverable. We cannot see when a bug arrived, what a strange piece of code was working around, or ask the commit why.

  • Good: history going back to the beginning, commits scoped to one change, messages that say why rather than what.
  • Bad: commits authored by root at three in the morning with messages like fix. Production was being edited in place.

What makes us quote a rebuild instead of a takeover

An agency recommending a rebuild is recommending the larger invoice, so weigh our opinion accordingly and get a second one. Takeovers are cheaper, faster to value, and the existing application already encodes years of business rules written down nowhere else. Ugly code does not clear the bar. These findings do, usually two or three together.

  • The schema is the specification. Production has tables nobody can explain. The takeover starts with archaeology of unknown length, and unknown length is the thing we will not fix-price.
  • The application cannot be installed from scratch. Private forks, a missing lock file, hand-patched vendor code. If we cannot stand it up on a new machine, we cannot safely change it on the old one.
  • The same rule computed in three places with three answers. A tax calculation in a controller, again in a Blade template, again in a database trigger. Reconciling those is the rebuild, whatever the project is called.
  • The business changed underneath the software. The code is fine and the model it encodes no longer matches the company. That is a product problem dressed as a technical one. The rebuild starts with a whiteboard session about what the business does now, not with composer create-project.

The third one usually starts life looking like this, and we see the shape often enough to draw it from memory.

php// app/Http/Controllers/OrderController.php, roughly as inherited
public function store(Request $request)
{
    $order = Order::create($request->all());

    $total = 0;
    foreach ($request->items as $item) {
        $line = $order->lines()->create($item);
        $total += $line->qty * $line->unit_price;
    }

    if ($request->coupon) {
        $c = DB::select(
            "select * from coupons where code = '" . $request->coupon . "'"
        );
        $total = $total - ($total * $c[0]->percent / 100);
    }

    DB::update('update orders set total = ? where id = ?', [$total, $order->id]);
    Mail::to($order->email)->send(new OrderPlaced($order));

    return redirect()->route('orders.show', $order);
}

Every rule the business runs on is in there and none of it can be called from anywhere else, so the day the same order has to come from a mobile API, the rules get copied and the copies drift. The coupon lookup goes straight from the request into a SQL string, an injection hole on the pricing table. The money is floating point. There is no transaction. And the raw DB::update bypasses Eloquent model events, so an observer on Order that writes the audit row never fires. None of that alone is a rebuild. All of it, on every endpoint, with no tests to unpick it against, is.

Ugly code is not a reason to rebuild. Code that cannot be installed, tested or explained is.

, Ravi Rai

What we got wrong doing this

Early on we quoted takeovers from a read-only review of the repository, because the code looked orderly and standing up a copy felt like work we could do after signing. Orderly code told us nothing. Migrations that run cleanly on an empty database say nothing about how far production has drifted from them, and the gap is the thing that sets the price. When we found it after signing, the difference came out of our side, not the client's. Nothing gets quoted now until the schema diff has been run.

The second mistake took longer to admit. We used to give the assessment verbally, because it felt friendlier. A verbal assessment cannot be checked later, cannot be shown to a co-founder who missed the call, and quietly becomes whatever both sides remember. Every takeover assessment we do now is written before any code is touched, including the part where we say a rebuild would serve you better than paying us to patch this. Written scope before code, a flat price, and the repository in the client's name from day one.

What to get in writing before your developer leaves

Everything above is cheap while the outgoing developer is still engaged and expensive afterwards. Send this list in writing before the last working day, tie it to the final payment, and check that what arrives actually works instead of filing it unopened.

  1. Open the repository yourself and count the commits. If there is one, or if what arrives is a zip file, you have learned something important before spending anything. It should sit in an account the business owns.
  2. Check who owns the domain, the DNS and the hosting account, not who manages them. The locked-out pattern and how to recover from it is written up in what we fix when businesses hire us to rebuild. The short version: a personal address on any of the three gets fixed this week, while the person is still answering.
  3. Ask for the most recent backup as a file, then watch it restored. Time it. That number is your real recovery window.
  4. Ask what happens if the server dies tonight. A good answer is a document or a script. A bad answer is a person's name, and if it is, ask them to write it down while they are still being paid.
  5. Ask for the account register. Every service that sends a message, takes a payment or stores a file, with the owning email and where the two-factor recovery codes now live. In your password manager, not theirs.
  6. Ask for a written note of the known broken things. Honesty about which ones is the one item that becomes unobtainable the day the developer leaves.

What happens after the first day

The output of day one is a written assessment: version and support status, schema drift, test reality, background job reality, secret hygiene, dependency risk, and a recommendation with the reasoning attached. A takeover recommendation names the first three things we would fix and why in that order. A rebuild recommendation says so plainly. How we scope and run that work is on our Laravel development page.

If you have inherited a Laravel application and you are not certain what you are holding, send us read access and we will run this sequence and write down what we find, including the version where a takeover is not worth paying for.

Send us the repository

Frequently asked questions

How long does a Laravel takeover audit actually take?
The sequence in this post is one working day once access is in place, and getting access is usually the slow part rather than the reading. If the production schema has drifted badly, or the application cannot be installed from a clean checkout, the audit stops being a day and becomes a short scoped piece of work. We say that out loud rather than quietly billing more days.
Can you take over a Laravel app if the original developer has already gone?
Usually yes, provided we can get the repository, the server and the database. The real blockers are access rather than attitude: a private package fork on an account nobody can reach, or a payment gateway account with two-factor codes going to a phone nobody can call, will stop a takeover in a way an uncooperative developer will not. Get account ownership transferred before the relationship ends, because it is much harder afterwards.
Is an old Laravel version on its own a reason to rebuild?
No. No. A version gap on its own is scheduled work with a published guide for each step. What turns a version gap into a rebuild conversation is the combination of an unsupported PHP runtime, an abandoned package on the critical path, and a schema no migration describes, because then the upgrade cannot be done incrementally with tests to catch the damage. What turns a version gap into a rebuild conversation is the combination of an unsupported PHP runtime, an abandoned package on the critical path, and a schema no migration describes, because then the upgrade cannot be done incrementally with tests to catch the damage.
What can I check myself without a technical background?
Ask the outgoing developer to set the project up from the repository on a machine that has never run it, on a call, with you watching. Whether that takes twenty minutes or three days tells you more about your takeover cost than any code review would, and every step they take that is not written in the README is knowledge you were about to lose. Then read the registrant email on your domain and watch a backup being restored.
RR
Written by
Ravi Rai

Founder of buildbyravirai, a web development agency based in Noida, India. 5+ years shipping Next.js, WordPress, Shopify, and Laravel projects for clients in India, USA, Canada, and the UK.

Backend and APIs

Need this backend built properly the first time?

Billing engines, notification services, integrations with the accounting stack you already run. We build the unglamorous parts that decide whether the product holds up at volume.