— Article — № 070

070 —AI

Scoping AI features on a 2014 WordPress site: a sober method

A client asks for 'just a small AI feature' on a 2014 WordPress site. Here is the scoping method that keeps you from quoting blind on a PHP 5.6 codebase.

Overhead photo of paper AI scope worksheet, PHP 5.6 audit sheet, manila wp-content folder, brass plate, red wax seal.
Hero · staged still№ 070

The Loom came in at 23:41 on a Tuesday. A founder of a five-person Dutch agency, talking over a shared screen of his client's wp-admin, saying the words every freelancer has heard this year: they just want a small AI feature, nothing crazy, maybe a chatbot on the product pages and a thing that rewrites the descriptions. The site was built in 2014. The theme folder had functions.php at 2,400 lines. Advanced Custom Fields was on v4. The header said Generator: WordPress 4.9.8.

Scoping an AI feature on a legacy site like that is not a technical problem first. It is a triage problem. The client thinks they are asking for one ticket. You are looking at six months of accumulated decisions someone else made, half of which will fight whatever you bolt on. This post is the scoping method we use before we quote a single hour.

The conversation the client thinks they are having

The client has seen ChatGPT write product copy. They have seen a competitor add a support widget. They have read that OpenAI's API costs fractions of a cent per call. From their seat, the ask is reasonable: pipe their product catalogue into a model, get better descriptions, maybe a chat thing on the side. They are budgeting two to four thousand euros and a fortnight.

The conversation you are actually about to have involves PHP version constraints, a database that has been migrated twice and renamed once, an options table with 14,000 autoloaded rows, a caching plugin that intercepts REST routes, and the question of whether the host even allows outbound HTTPS to a third-party endpoint without a whitelist request. None of this is in the brief.

The scoping method exists so you can have the second conversation without scaring the client out of the project. It runs in four passes.

Pass one: the read-only audit

Before any quote, before any architecture sketch, you spend two hours read-only on the live site. Not staging. The live site is the source of truth; staging is whatever someone copied in 2019. You are answering five questions, in this order.

The actual runtime

Drop a phpinfo.php in a non-public path, hit it, delete it. You want the PHP version, the loaded extensions, memory_limit, max_execution_time, and whether allow_url_fopen is on. A 2014 site will often still be on PHP 7.4 or, worse, 5.6 with a hosting panel that quietly refuses upgrades because a plugin in mu-plugins breaks above 7.2.

If you are on anything below PHP 8.1, the OpenAI PHP clients you would reach for first do not install. The official openai-php/client requires 8.1+. You can roll your own with wp_remote_post, but that is a line item now, not an assumption.

The database shape

Connect to MySQL. Run three queries and write the answers down.

SELECT COUNT(*) FROM wp_posts WHERE post_status = 'publish';
SELECT COUNT(*) FROM wp_options WHERE autoload = 'yes';
SELECT table_name, ROUND(data_length/1024/1024,1) AS mb
  FROM information_schema.tables
  WHERE table_schema = DATABASE()
  ORDER BY data_length DESC LIMIT 10;

If wp_options autoload count is above 1,000, every page load is already dragging. If wp_postmeta is the biggest table by a wide margin, ACF is probably storing a serialised blob per post and you cannot query it without LIKE '%...%'. Both facts change how you design the AI feature's data layer.

functions.php and mu-plugins

Open wp-content/themes/<active>/functions.php and wp-content/mu-plugins/. You are looking for filters on the_content, hooks on save_post, custom REST routes, and anything that mentions curl_exec or file_get_contents against an external host. This is where the previous developer's surprises live. A save_post hook that fires four other hooks is the reason your "regenerate description" button will time out.

Host egress policy

Some shared hosts block outbound HTTPS to anything except their own update servers. Test it from the box itself, not from your laptop.

curl -sS -o /dev/null -w "%{http_code}\n" https://api.openai.com/v1/models \
  -H "Authorization: Bearer $KEY"

A 401 is fine. A timeout means you need a whitelist ticket, which on some Dutch hosts takes two weeks.

The .htaccess reality

Read the root .htaccess. If there is a caching layer rewriting requests before they hit PHP, your new REST endpoint will be cached as HTML the first time it returns, and every subsequent call will hand back stale JSON. The fix is one block, but only if you know it is there.

<IfModule mod_rewrite.c>
  RewriteRule ^wp-json/myplugin/ - [L]
</IfModule>

Pass two: the feature, translated

Now translate the client's ask into the smallest concrete thing that ships. "A chatbot on the product pages" is not a unit of work. "A floating widget on single-product templates that POSTs to /wp-json/agency/v1/chat, which proxies to a model with a 1,200-token system prompt scoped to that product's title, description, and three custom fields" is a unit of work.

Do this in writing, in their language, before you talk hours. Two columns: what the client said, what that means in the codebase. The translation is what they are actually buying. If they will not sign off on the translation, you are not ready to quote.

Pass three: the failure modes you quote for

The reason small AI features go over budget on old sites is not the AI part. It is the failure handling. You quote for these explicitly, by name, as line items.

  • The model is slow or down. Your endpoint needs a timeout below the host's max_execution_time and a graceful fallback. On a 30-second host limit, you set the HTTP client to 20 seconds and return a cached or canned response on timeout.
  • The cache plugin caches the AI response. WP Rocket, W3 Total Cache, and LiteSpeed will all happily cache a REST response as if it were static. You either exclude the route or send Cache-Control: no-store from PHP.
  • The content filter rewrites the output. If the site runs anything that hooks the_content (Yoast, a translation plugin, a custom shortcode parser), your AI-generated text will pass through it. Test the round-trip before you promise WYSIWYG.
  • The database write blows up an existing hook. Saving an AI-generated description via wp_update_post fires save_post, which on a 2014 site might fire four other things, one of which sends an email. Use wp_update_post( $args, true, false ) with the third argument set to skip hooks, or write directly to wp_posts with a $wpdb->update call and a clear comment about why.
  • The API key leaks. If the client's developer copies the production wp-config.php to a staging box, the key goes with it. Store it via wp-config constants only, never in the options table, and rotate on handover.

Each of those is a paragraph in the proposal. The client does not have to understand them, but they need to see that you do. This is what separates a quote that holds from a quote that doubles in week three.

Pass four: the version-history question

The last pass is the one most freelancers skip. Before you ship an AI feature that writes to the database, you decide how to undo it. The model will, at some point, generate a product description that the client hates, or worse, that is wrong. If your only rollback is "restore last night's database backup," you have just become the person who lost a day of orders to fix a typo.

On greenfield work this is trivial. On a 2014 site, it is a design decision. The options are: write to a custom table and only mirror to wp_posts on approval, snapshot the original post_content in postmeta before each write, or sit on top of a tool that gives you a version history for every file and row change. Pick one, price it, and put it in the proposal.

When we built Pier we ran into this exact thing on a customer's Magento 1 store: an AI rewrite job that touched 800 product descriptions and needed to be partially reverted six hours later. The way we ended up handling it was making every edit, file or row, undoable from a single history view, so the rollback was a click instead of a restore. The MySQL editor ships with that built in now because of that incident.

What to do today

If you have an AI-feature ask sitting in your inbox right now, do pass one before you reply. Two hours, read-only, the five questions above. You will either send back a quote you trust or a one-paragraph email that says the site needs a PHP upgrade first. Either is better than the quote you would have sent tonight.

— Questions —

Why not just quote a fixed price for a small AI feature?

Because on a 2014 codebase, the AI integration is rarely the expensive part. The expensive parts are PHP version constraints, host egress rules, and cache-plugin interference, none of which are visible from a brief.

Is PHP 7.4 enough to run modern AI clients?

For the official openai-php/client, no. It requires PHP 8.1+. You can fall back to wp_remote_post against the REST API directly, but quote it as a deliberate choice, not an oversight.

Should AI-generated content write straight to wp_posts?

Only if you have a rollback path. Either snapshot the original post_content to postmeta before the write, or stage edits in a custom table and only promote on approval.

How do I stop a caching plugin from caching my AI REST route?

Exclude the route in the plugin's settings and send Cache-Control: no-store from PHP. On Apache, an early RewriteRule for the wp-json path keeps mod_rewrite out of the way.