— Article — № 088

088 —Business

The five-minute pre-quote audit: ls, curl, and SQL counts

The fixed-price quote that became a three-week migration usually starts with skipping a five-minute audit. Here is the exact checklist we run before naming a number on legacy work.

Overhead photo on bone linen: checklist, brass stopwatch at 5:00, manila folder, tally sheet, red wax-sealed envelope.
Hero · staged still№ 088

An agency forwards a Loom at 23:41. The brief is "small WordPress fix on a client checkout that won't go through, can you quote it today?" Three screenshots, a staging URL, FTP credentials in a 1Password vault. The temptation is to quote half a day and move on. We do not, because we have learned what the first five minutes after the credentials arrive can save us.

The pre-quote audit on a legacy site is not a discovery sprint. It is the difference between a number you can defend and a number you regret. What follows is the exact sequence we run before we put a euro figure in a reply email. Five minutes, three terminal panes, no app installed yet.

The directory listing comes first

Before you read the brief again, list the document root. Not in a file manager. In a shell, with timestamps.

ssh deploy@client-host.tld
cd /var/www/html
ls -la
ls -la wp-content/
ls -la wp-content/mu-plugins/ 2>/dev/null
ls -la wp-content/plugins/ | wc -l
du -sh wp-content/uploads/

What you are looking for is not in the brief. It is the shape of the working tree. A document root that contains radio.php, m.php, lock360.php or any other lowercase noun PHP file at the top level is a compromise signal, almost always. A wp-config.php.bak sitting next to wp-config.php is the previous developer's resignation letter. An advanced-cache.php or object-cache.php drop-in inside wp-content/ means a caching layer (W3 Total Cache, Redis Object Cache, LiteSpeed) you will need to reason about every time you change behaviour.

The two numbers that already matter: how many directories sit in wp-content/plugins/, and how big wp-content/uploads/ is. A 48-plugin site with 31 GB of uploads is a different quote from a 9-plugin site with 800 MB, regardless of what the brief says.

Headers before SQL

Open a second pane. Hit the live URL with curl, not a browser. Browsers lie by being too helpful.

curl -sI https://client-site.tld/ | sort
curl -sI https://client-site.tld/wp-login.php
curl -s  https://client-site.tld/wp-json/ | head -c 400
curl -sI https://client-site.tld/?p=1

You are reading the headers for four things. The PHP version (X-Powered-By: PHP/7.4.33 on a site you are about to quote in 2026 means PHP end-of-life work is in scope, see the php.net supported versions list). The server (Apache/2.4.6 (CentOS) is EL7, also EOL, which changes the migration conversation). The CMS confirmation (a Link: <https://client-site.tld/wp-json/>; rel="https://api.w.org/" header is WordPress; X-Drupal-Cache is Drupal; X-Magento-Tags is Magento). And the absence of security headers, which the OWASP Secure Headers project catalogues better than any blog post will.

What you do not want to see, in any order: Set-Cookie: PHPSESSID=... without HttpOnly; Secure, missing Strict-Transport-Security, a Server header advertising the patch version, or a 200 OK on /wp-config.php~ (yes, this still happens in 2026).

Three SQL counts that price the job

Third pane. Open the database. The brief mentioned checkout. The database tells you whether checkout is the actual problem or the symptom.

-- 1. Real publish volume
SELECT post_type, post_status, COUNT(*) AS n
FROM wp_posts
GROUP BY post_type, post_status
ORDER BY n DESC
LIMIT 20;

-- 2. The autoload bloat that slows every page
SELECT
  ROUND(SUM(LENGTH(option_value)) / 1024 / 1024, 2) AS autoload_mb,
  COUNT(*) AS rows
FROM wp_options
WHERE autoload = 'yes';

-- 3. The join cost on every WP_Query
SELECT COUNT(*) AS postmeta_rows FROM wp_postmeta;
SELECT meta_key, COUNT(*) AS n
FROM wp_postmeta
GROUP BY meta_key
ORDER BY n DESC
LIMIT 10;

The autoload query is the one most engineers skip and most quote regrets begin with. Any value above 1 MB is doing damage on every uncached request; we have seen 47 MB on a site whose owner could not understand why TTFB hovered at four seconds. The fix is usually half an hour. Diagnosing it after you have already quoted the checkout job is what costs you the weekend.

The postmeta count tells you whether someone has been using ACF, WooCommerce and an event plugin together without ever pruning. A row count past four million on a site with 1,200 published posts is a story you want to hear before you commit, not after.

The .htaccess block decides the difficulty

Last sixty seconds of the pre-quote audit. Read the root .htaccess. Not skim, read.

cat .htaccess
cat wp-admin/.htaccess 2>/dev/null
find . -name ".htaccess" -not -path "./wp-content/cache/*" 2>/dev/null

You are reading for four patterns. A RewriteRule chain that is not the WordPress default (anything beyond the canonical block from the Apache htaccess docs) means a previous developer encoded business logic in URL rewrites, and you will inherit it. An AddHandler application/x-httpd-php74 .php line means the host pins PHP version per-directory, so a server-wide upgrade will not move this site. SecRule directives mean mod_security is on, and your future WooCommerce admin AJAX calls will be 403'd unpredictably. A Require ip block on wp-admin means the previous agency's office IP is whitelisted, yours is not, and you will discover this at 11 PM on Friday.

The five numbers that change the quote

Once the three panes have spoken, the quote is a function of five numbers, not the brief:

  1. PHP version on the running site. Anything below 8.1 means upgrade work is in scope whether the client asked for it or not.
  2. Autoload bytes. Over 1 MB, add a performance pass. Over 10 MB, add two days.
  3. Active plugin count. Read from wp_options where option_name = 'active_plugins', not the filesystem. The delta between filesystem and database is the abandoned-plugin count, and each one is a future support ticket.
  4. Postmeta rows per published post. Above 200 and the site's slowness is structural, not a caching tweak.
  5. Last-modified date on wp-content/uploads/<current-year>/. If it is more than four months old, the site is effectively abandoned, and the brief is probably a rescue, not a fix.

These five numbers fit in the first paragraph of your reply email. They are also the five numbers the client cannot argue with, because they came from their own server.

What we did with the checklist

For two years we ran this pre-quote audit by hand on every inbound. Three terminal tabs, a notes file, a spreadsheet of numbers per client. It worked, and it was tedious enough that we sometimes skipped the SQL pane when the brief sounded simple. The brief always sounds simple.

When we built Pier we ran into this exact thing on our own client work, so we wired the audit directly into the dock screen: once an FTP and MySQL connection are saved, the directory listing, the response headers, and the three SQL counts above run together and the results sit beside the chat. The MySQL editor remembers the autoload query the same way a good editor remembers your last grep, and the version history means the moment you touch .htaccess mid-audit, the original is one click back.

The smallest thing you can do today: save the three SQL queries above as a gist titled pre-quote-audit.sql, and add a shell alias quote-prep that runs the ls and curl -sI sequence in one go. The next time a Loom lands at 23:41, you will be five minutes from a number you can defend.

— Questions —

How long should the pre-quote audit actually take?

Five to ten minutes from receiving credentials, assuming SSH or FTP and a MySQL prompt. Longer if you have to chase access, and that delay itself is a useful signal about the engagement.

What if the client only gives me SFTP, no shell?

ls -la over SFTP still works, curl runs from your own machine, and any serious client will provide phpMyAdmin or a MySQL bastion when you ask for it in writing before quoting.

Do these queries differ for WooCommerce or Magento?

The autoload check is WordPress-specific. On Magento, check core_config_data row size and EAV attribute table counts; on Drupal, look at cache_* table sizes and the watchdog row count.

Should I share the audit numbers with the client before quoting?

Yes, in the reply email. Five concrete numbers from their own server move the conversation off vibes and onto scope, every time, and they make the price feel earned rather than guessed.