Vibe coding: the hidden cost of AI built architectures

TL;DR

Difficulty: Intermediate

AI writes code that runs. It does not make the architectural decisions nobody asked it for, and those decide whether a system survives real customers.

  • The database fails first: N+1 queries, missing indexes, a database chosen by accident and a schema that cannot grow.
  • Frameworks have a ceiling: Streamlit, Gradio and Dash are for data apps and demos, Flask and FastAPI leave the structure to you, Django and Rails decide it for you.
  • Language matters less than architecture, but pick it for the team and the workload.
  • Recover in order: tests first, then refactor in place, replace piece by piece, and rewrite only as the last resort.

AI can build a working prototype in an afternoon. What it does not do, unless someone asks, is make the decisions that let that prototype survive real traffic. This article shows where vibe coded systems break first, what each popular framework is really for, the warning signs to look for in a codebase, and the three ways back.

The prototype works, and that is the trap

Someone has an idea, opens ChatGPT or Claude, and starts building. The AI writes the code, the prototype works, the founder is delighted. Six months later the database is struggling, pages take seconds to load, and an engineer is brought in to say that large parts need rebuilding.

The name for this is vibe coding. Andrej Karpathy coined it in February 2025 for a style of building where you "fully give in to the vibes" and "forget that the code even exists": accept every change, paste error messages back in, never read the diffs. He was clear that it suits "throwaway weekend projects".1 The trouble starts when the weekend project becomes the business.

The AI writes code that runs. What it does not do, unless someone asks, is make the decisions that let a system scale and survive real customers. This article shows where vibe coded architectures fail first, what each popular language and framework is really for, and the warning signs to spot early.

AI writes code, not architecture

To be clear: AI coding assistants are useful. I use them daily. But writing code and designing a system are different jobs. Writing code is turning logic into syntax, and AI is good at that. Designing a system means understanding how the data will flow and grow, seeing the bottleneck before it arrives, trading one concern off against another, and knowing what you do not yet know. A model does not know your business or your scale. It does not know that your "simple users table" will hold 10 million rows in 18 months. It answers the request in front of it.

The research says the same thing from two sides. In a randomised controlled trial run by METR in 2025, 16 experienced open source developers completed 246 real tasks in projects they knew well. With AI tools allowed they took 19% longer, while believing afterwards that the tools had made them 20% faster.2 And when a research team asked GitHub Copilot to complete 89 security relevant scenarios, about 40% of the 1,689 programs it produced were vulnerable.3 Speed you can feel is not the same as quality you can rely on.

An example: a founder asks for "a page that lists all orders with the customer's name". It works on 20 test orders, and nobody asks how it behaves on 20,000. That question is the architecture, and the next section answers it.

The database is where vibe coding fails first

The database is where architectural debt piles up fastest. A schema decision made in week one becomes a migration across millions of rows in year two.

The N+1 query problem

This is the most common performance killer in vibe coded applications. It happens when code fetches a list, then makes one more database query for every item in the list.

# The vibe coded version (N+1 queries)
orders = Order.objects.all()  # 1 query
for order in orders:
    print(order.customer.name)  # 1 more query per order

# 1,000 orders = 1,001 database queries
# The corrected version (one query with a join)
orders = Order.objects.select_related("customer")
for order in orders:
    print(order.customer.name)  # no additional queries

Each line is correct on its own, which is why it slips through. Nothing in order.customer looks like a database call, but in Django it is one, as in most ORMs (the object relational mappers that turn rows into objects).

The number of queries grows with the list:

Qlazy=N+1Q_{\text{lazy}} = N + 1

Here QlazyQ_{\text{lazy}} is the number of queries the page makes when each customer is loaded separately, and NN is the number of orders on the page. Every extra order adds one more query, so the marginal cost of an order never falls. With a join, QeagerQ_{\text{eager}} stays at one or two, whatever NN is. Foundations: marginal versus average

The time the page spends waiting on the database is then:

T=Q×tqT = Q \times t_q

Here TT is the total waiting time, QQ the number of queries and tqt_q the average time for one query, including the round trip to the database server. Foundations: ratios and rates

Worked example: an order list at a Waterford tea shop

Reginald Teas is an invented online tea shop in Waterford, at reginaldteas.example. Its admin page lists the last 400 orders with each customer's name. Assume, for the example, that one small query takes 2 milliseconds including the round trip.

  1. The vibe coded page makes Q=400+1=401Q = 400 + 1 = 401 queries.
  2. The waiting time is T=401×2=802T = 401 \times 2 = 802 milliseconds. Slow, but tolerable, so nobody notices.
  3. In December the owner exports 10,000 orders. That is 10,001×2=20,00210{,}001 \times 2 = 20{,}002 milliseconds, 20 seconds of staring at a spinner.
  4. The corrected page makes one query with a join. Even if that query takes 10 milliseconds, the page waits 10 milliseconds, for 400 orders or for 10,000.

Queries per page, lazy against eager loadingThe number of database queries grows in a straight line with the number of orders when each customer is loaded separately, and stays at one when the customers are joined in.Database queries for one page of orders05,00010,00015,00020,000101001,00010,000Loaded one by one (N+1)Joined in one queryQueriesOrders on the page

The horizontal axis is the number of orders on the page, the vertical axis the queries it sends. Loading one by one climbs with every order, the join stays flat at one. Two pages that look identical in development behave completely differently at scale.

Missing indexes

An index is to a table what the index at the back of a book is to its pages: a sorted lookup that takes you straight to the right place. Without one, the database reads every row to find the ones you asked for, a full table scan.

-- A query that runs many times a second
SELECT * FROM orders WHERE user_id = 123 AND status = 'pending';

-- The index that serves it
CREATE INDEX index_orders_on_user_id_and_status ON orders (user_id, status);

Columns you filter, join or sort on often usually need one, and the column order matters: PostgreSQL's documentation explains that a multicolumn index works best when the query constrains its leading (leftmost) columns.4 Nothing in a request to "add an orders table" asks for an index, so the cost stays invisible until the table is large.

The difference in work is the difference between reading every row and walking down a sorted tree:

Sscan=RSindex≈log⁡2RS_{\text{scan}} = R \qquad S_{\text{index}} \approx \log_2 R

Here SscanS_{\text{scan}} is the number of rows a full scan reads, RR is the number of rows in the table, and SindexS_{\text{index}} is roughly the number of steps a sorted lookup needs, which grows with the logarithm of RR. Real database indexes branch much more widely than two at each step, so they need even fewer steps. Foundations: logarithms

Worked example: bookings in Cork

Mahon Bookings is an invented booking platform for gyms in Cork, at mahonbookings.example. Its bookings table holds 12 million rows, and the members' "my bookings" page filters by member and status.

  1. Without an index the database reads all Sscan=12,000,000S_{\text{scan}} = 12{,}000{,}000 rows for every page view.
  2. With an index on (member, status) a sorted lookup needs about log⁡212,000,000≈23.5\log_2 12{,}000{,}000 \approx 23.5 steps, so around 24.
  3. At 50 page views a second, the scan version asks the database to read 600 million rows every second. That is not a slow page, it is an outage.

The wrong database for the access pattern

Vibe coding tends to start on SQLite or PostgreSQL, because that is what the tutorials and the model reach for. Both are good choices for a lot of work. The question is whether the choice was made for your access pattern or by accident.

Access patternUsually a good fitOften a mistake
Relational data with many joinsPostgreSQL, MySQLA document store with joins rebuilt in code
Documents whose shape varies a lotMongoDB, or PostgreSQL with JSONB used deliberatelyDozens of nullable columns
Very high write volume, time seriesTimescaleDB (a PostgreSQL extension), ClickHousePlain tables with no partitioning or retention plan
Full text searchPostgreSQL full text search, Elasticsearch, MeilisearchLIKE '%term%' on a large table
A single small app on one serverSQLiteA database cluster nobody needs yet

SQLite deserves a fair word. Its documentation says it suits most low to medium traffic websites, with fewer than 100,000 hits a day as a conservative guide, and that its real limit is one writer at any instant.5 Rails 8 even runs jobs, caching and WebSockets on it.6 The mistake is not SQLite, it is never asking how many writers you will have.

I have seen vibe coded projects store time series data in PostgreSQL as a new row per data point, with no plan for volume, then wonder why queries take 30 seconds. I have seen projects store highly relational data in MongoDB, then spend months fighting $lookup aggregations that a SQL join would answer in milliseconds.

Worked example: meter readings in Galway

Renmore Meters is an invented energy monitoring start up in Galway, at renmoremeters.example, with 2,000 sensors that each send one reading a minute. The yearly volume is:

V=s×f×dV = s \times f \times d

Here VV is the number of rows a year, ss the number of sensors, ff the readings per sensor per day and dd the number of days. It is a rate multiplied out over time. Foundations: ratios and rates

  1. Readings a day: 2,000×1,440=2,880,0002{,}000 \times 1{,}440 = 2{,}880{,}000, because a day has 1,440 minutes.
  2. Readings a year: 2,880,000×365=1,051,200,0002{,}880{,}000 \times 365 = 1{,}051{,}200{,}000, just over a billion rows.
  3. At that volume you need partitioning by time, hourly roll ups and a retention period. None of it comes up in a prompt that says "store the readings".

A schema that cannot grow

Vibe coded schemas are built for today's requirement. Take addresses: one address column on users, then a billing address, then validation, then geocoding. The vibe coded answer adds columns. The structural answer is a separate addresses table, linked to the user, from day one.

-- Vibe coded (week 1)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    address TEXT
);

-- Vibe coded (week 52, many migrations later)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255),
    address_line_1 TEXT,
    address_line_2 TEXT,
    address_city TEXT,
    address_eircode TEXT,
    billing_address_line_1 TEXT,
    billing_address_line_2 TEXT
    -- ... and more every quarter
);

-- Structured (week 1)
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    name VARCHAR(255)
);

CREATE TABLE addresses (
    id SERIAL PRIMARY KEY,
    user_id INTEGER NOT NULL REFERENCES users(id),
    kind VARCHAR(20) NOT NULL,  -- 'delivery', 'billing'
    line_1 TEXT,
    line_2 TEXT,
    city TEXT,
    eircode TEXT,
    country_code CHAR(2),
    latitude DECIMAL(10, 8),
    longitude DECIMAL(11, 8)
);

Worked example: a florist's second address

Corbally Florals is an invented florist in Limerick, at corballyflorals.example. Customers want flowers sent to a friend while billed at home, and corporate customers want three office addresses on file.

  1. With address columns on users, each extra address means a migration, a form change and new code wherever addresses are read.
  2. With an addresses table, a second or a tenth address is just another row.

Choose the language for the team and the workload

The language sets your hiring pool, your libraries, your performance ceiling and how hard deployment will be. It matters less than architecture, but the wrong one adds friction at every step.

Python, the default in most AI sessions

Python has a huge ecosystem, above all for data science, and is easy to start with, which is why so many vibe coded projects begin there. Its weaknesses are often overstated. The Global Interpreter Lock (GIL) has long stopped Python threads from running Python code in parallel on several cores, although since Python 3.13 there is an optional free threaded build with the GIL disabled.7 Dynamic typing lets through errors a compiler would catch, unless you add type hints and a checker.

Use it for data pipelines, machine learning, glue between systems and teams that know it. Think twice for very high throughput services.

JavaScript and TypeScript, the full stack temptation

One language in the browser and on the server is attractive, TypeScript adds the type safety plain JavaScript lacks, and the event loop handles waiting on the network well. The cost is a small standard library, so almost everything is a dependency. Use it for the frontend (no choice there), real time features and frontend heavy teams.

Ruby, built for productivity

Rails is very productive for complex business logic, its conventions remove a whole class of decisions, and its testing culture is strong. Ruby is slower than compiled languages, the hiring pool is smaller, and too much metaprogramming gets hard to follow. It is my own choice for eCommerce on Solidus and for SaaS products, and the reasons are in why I use Next.js and Ruby on Rails.

Go, pragmatic at scale

Go is compiled, fast, has concurrency built in through goroutines and is deliberately simple. Generics only arrived in Go 1.18 in 2022,8 and its explicit error handling makes code verbose. Use it for high throughput APIs, network services and infrastructure tools.

Elixir, the concurrency specialist

Elixir runs on the Erlang virtual machine, which has built in support for concurrency, distribution and fault tolerance, and runs in large telecom systems at Ericsson.9 The ecosystem is smaller. Use it for real time features and very many simultaneous connections.

Rust, when performance cannot give

Rust gives performance close to C with memory safety and no garbage collector. It is slow to learn and more than most web applications need. Use it for systems programming and performance critical services.

Worked example: a language for a Cork recruitment platform

An invented recruitment platform in Cork has two Python developers and needs listings, applications, employer accounts and invoicing for a few thousand users.

  1. Ordinary web traffic with plenty of business rules: raw speed is not the constraint.
  2. The team's knowledge is, so Python is reasonable.
  3. The real decision is the framework: one with an ORM, migrations, authentication and an admin built in. That points to Django, not a dashboard library or a bare API framework.

Frameworks: what each is for, and where it breaks

These are the frameworks I see vibe coded projects reach for. Each is good at something. The problem is using it for something else.

Streamlit: a data app that became a product

Streamlit turns a Python script into a web interface with very little code, which is why AI tools love generating it. Its execution model is the catch. Every time a user changes a widget, Streamlit reruns the script from top to bottom. Its own documentation says long running functions then "run again and again", and offers caching as the fix, with cached values shared across all users by default.10 One server process does the computing for every user, so it has to be sized for the concurrent load.11

import streamlit as st
import pandas as pd

@st.cache_data(ttl=3600)  # without this line the query reruns on every click
def load_sales():
    return pd.read_sql("SELECT * FROM sales", connection)

df = load_sales()
st.dataframe(df)

Some old criticisms no longer hold: Streamlit has multipage apps with st.navigation12 and login through an OpenID Connect provider with st.login.13 But it is not an application framework. There is no ORM, no migrations, no background jobs, and logic, data access and presentation share one script.

Worked example: a dashboard in Limerick

An invented wholesaler in Limerick builds its sales dashboard in Streamlit. The main query takes 30 seconds. Twenty staff use the dashboard, and each changes a filter about 15 times a day.

The daily database time is:

D=u×c×tD = u \times c \times t

Here DD is the database time a day in seconds, uu the number of users, cc the interactions per user per day and tt the seconds per query. Foundations: ratios and rates

  1. Without caching: D=20×15×30=9,000D = 20 \times 15 \times 30 = 9{,}000 seconds, two and a half hours of the database doing the same work every day.
  2. With st.cache_data refreshing once an hour over an eight hour working day: 8×30=2408 \times 30 = 240 seconds.
  3. The fix is one line, if the person shipping it knows the rerun model exists.

Gradio: built for demos

Gradio wraps a Python function, typically a machine learning model, in a web interface. It now offers a password option on launch() and a "Sign in with Hugging Face" login, but its own guide is plain about the limits: the password option is "a basic layer of access control" without multi factor authentication, rate limiting or lockout.14 There are no accounts, payments or saved results in the application sense.

An example: an invented Galway start up puts three models behind Gradio and calls it a platform. When customers ask for accounts, invoices and a history of results, all of it has to be built around the models from scratch. Gradio did its job, a demo. Nobody decided when the demo should become a product.

Plotly Dash: dashboards, not applications

Dash builds analytical web apps in Python on top of React components, with a callback model that is more structured than Streamlit. Complex apps can turn into a web of callbacks that is hard to trace:

@app.callback(
    Output("revenue-graph", "figure"),
    Output("orders-graph", "figure"),
    Output("orders-table", "data"),
    Input("region-dropdown", "value"),
    Input("channel-dropdown", "value"),
    Input("date-range", "start_date"),
    Input("date-range", "end_date"),
    State("filters-store", "data"),
)
def update_everything(region, channel, start, end, filters):
    # Which output changed? All of them, every time.
    ...

Dash's documentation warns that most web servers time out after 30 seconds and that long running callbacks can tie up every worker, hence its background callbacks.15 A good internal dashboard, but accounts and payments are yours to build.

An example: change any of the five inputs above and all three outputs recalculate. If each needs a two second query, every click costs six seconds, even when only the table needed updating. Splitting it into three callbacks, each listening only to what it uses, is an architectural decision, not a syntax fix.

Flask: small on purpose

Flask gives you routing and little else, on purpose. You add the ORM (SQLAlchemy), migrations (Alembic), login, forms and an admin yourself. In a vibe coded project that freedom is the problem, because every prompt makes a different choice.

Two common criticisms need correcting. Flask has supported async views since version 2.0, although as a WSGI application each request still ties up one worker, and its documentation points to Quart for async first work.16 And its global looking objects (request, g, current_app) are context locals that a test client handles fine. Testing Flask is not hard. Testing a Flask project with no structure is.

An example: an invented Waterford start up builds its product in Flask over 18 months of prompts and ends up with database access done four ways and two login mechanisms, one per feature generated. The framework allowed it. A person deciding the structure on day one would not have.

Django: everything included

Django ships an ORM, migrations, authentication, an admin and forms. Decisions are made for you, which is exactly what a vibe coded project lacks. Instagram is among the sites the Django project lists as built with it.17

The ORM is convenient until you need performance. The nested N+1 problem is the classic case:

for order in Order.objects.all():           # 1 query
    print(order.customer.name)              # 1 query per order
    for item in order.items.all():          # 1 query per order
        print(item.product.name)            # 1 query per item

On async, the picture has moved on: Django supports async views under ASGI and, with some exceptions, async ORM queries, though its documentation says ORM async support is still being worked on.18

Worked example: a Django order page

Take 50 orders with three items each. The loop above makes:

Q=1+2N+NMQ = 1 + 2N + NM

Here QQ is the number of queries, NN the number of orders and MM the items per order: one query for the orders, one per order for the customer, one per order for the items, and one per item for the product. Foundations: marginal versus average

  1. Q=1+2×50+50×3=1+100+150=251Q = 1 + 2 \times 50 + 50 \times 3 = 1 + 100 + 150 = 251 queries.
  2. Rewritten as Order.objects.select_related("customer").prefetch_related("items__product"), it makes three: orders joined with customers, then all the items, then all the products.
  3. At 2 milliseconds a query, that is 502 milliseconds against 6, for the same page.

FastAPI: an API framework asked to be everything

FastAPI is a modern Python framework for APIs, with type hints, automatic OpenAPI documentation and async support. It is excellent at that, and it is not a full stack framework: there is no ORM, no migrations, no admin and no user management, so you assemble them.

The most common bug is about async, and the original version of this article got the fix wrong. FastAPI is not "async by default". If you declare an endpoint with plain def, FastAPI runs it in a thread pool, so a blocking database call does not freeze the server. If you declare it async def and then call blocking code inside, you block the event loop for everyone.19

# Blocks the event loop: async def with a synchronous database call
@app.get("/users")
async def list_users():
    return db.query(User).all()

# Fine: plain def, FastAPI runs it in its thread pool
@app.get("/users")
def list_users():
    return db.query(User).all()

An example: ten requests arrive at once and each makes a 200 millisecond blocking query inside async def. They run one after another, so the last caller waits 10×200=2,00010 \times 200 = 2{,}000 milliseconds. With plain def, the thread pool runs them side by side and each caller waits about 200 milliseconds. One keyword, a tenfold difference in the worst wait.

The architectural trap is FastAPI plus a separate React frontend generated in two halves: validation written twice, several authentication schemes, two repositories to deploy together.

Reflex: young and promising

Reflex lets you write a full stack web app in Python. Under the hood it compiles the interface to a React frontend connected to a FastAPI backend, and ordinary state handlers run on the server, with events and state updates travelling over WebSockets.20

That is the trade off. Every state change goes to the server and back, fine for internal tools, slow for very interactive screens. The ecosystem is young, and when something breaks between the Python and the compiled React, you need to understand both.

An example: an invented Kilkenny tool updates state on every keystroke of a search box. Assume a 120 millisecond round trip: a nine letter search sends nine events and the results trail the typing. Debouncing the input, or keeping that state in the browser, fixes it, if someone knows to ask.

Rapid development frameworks that carry the weight

Here moving fast does not have to mean rebuilding later. I wrote about what rapid development frameworks actually are separately. In short: conventions decided by thousands of production projects, so nobody invents them in a prompt.

Ruby on Rails

Rails carries serious weight in production: Shopify's core is a Rails modular monolith,21 and GitHub has written about upgrading its own large Rails application.22 The pitfalls are known and so are the solutions.

Convention means every Rails app is laid out the same way, so a new developer or reviewer finds things where they expect. Rails even has a built in guard against N+1 queries, strict loading, which raises an error whenever code lazily loads an association instead of loading it up front.23 (The popular Bullet gem does a similar job, but it is an add on, not part of Rails.)

# config/environments/development.rb
config.active_record.strict_loading_by_default = true

# Now this raises in development instead of quietly making N+1 queries
Order.all.each { |order| puts order.customer.name }

# This is what it wants
Order.includes(:customer).each { |order| puts order.customer.name }

Rails 8 brought more of the stack into the framework: Solid Queue for jobs, Solid Cache and Solid Cable on the database, and an authentication generator.6 Fewer separate choices for a prompt to get wrong.

Where Rails struggles: CPU heavy work, very low latency requirements and teams that will not follow its conventions. The scaling path is well trodden: caching, read replicas, background jobs, and pulling out a separate service only for the hottest paths.

An example: an invented Cork homeware shop turns strict loading on in development. When a generated admin page loops over 400 orders reading order.customer, it raises on the developer's screen instead of shipping. Adding includes(:customer) takes the page from 401 queries to two (the orders, then the customers).

Phoenix, for many connections at once

Phoenix brings Rails style productivity to the Erlang virtual machine. Its processes are lightweight, so one server can run an enormous number, and a failure in one does not take down the others. In 2012 WhatsApp reported more than 2 million TCP connections on a single Erlang server,24 and in 2015 the Phoenix team reached 2 million WebSocket connections on one machine in a benchmark.25 LiveView lets you build interactive pages with very little frontend JavaScript.

Fewer developers know it. An example: an invented Galway events start up needs live seat maps, with thousands of people watching the same map as seats sell. Many open connections each doing little is exactly the shape Phoenix handles well.

Laravel, on PHP

Laravel is a full stack PHP framework with good documentation, starter kits for authentication, queues and broadcasting, and PHP hosting is everywhere. Modern PHP is better than its reputation: PHP 8.0 brought union types, attributes, the match expression and a JIT compiler.26

Where Laravel struggles is mostly where PHP struggles, which is the next section.

Why PHP needs extra care

PHP has its own article. It gets you to "it works" very quickly, which is exactly what makes a vibe coded PHP project dangerous: it runs long before it is right.

The language itself

The standard library is inconsistent, so the argument order is a memory test:

strpos($haystack, $needle);        // haystack first
strstr($haystack, $needle);        // haystack first
in_array($needle, $haystack);      // needle first
array_search($needle, $haystack);  // needle first

Loose comparison with == converts types before it compares. PHP 8 made this much saner: since the "saner string to number comparisons" change, 0 == "" and 0 == "foo" are false, where PHP 7 said true.27 One trap remains, and it is the dangerous one: if both sides are numeric strings, PHP compares them as numbers.28

// PHP 8
0 == "0"         // true
0 == ""          // false (true before PHP 8)
null == false    // true

// Still true in PHP 8: both are numeric strings in scientific notation, both equal 0
"0e1234" == "0e5678"   // true

// So never compare secrets with ==
if (hash_equals($stored_hash, $computed_hash)) {
    // ...
}

Worked example: a password check that lets anyone in

An invented Limerick membership site stores unsalted MD5 hashes and checks logins with if ($stored == md5($input)). One member's stored hash happens to be 0e followed only by digits. Any input whose MD5 hash has the same shape now matches, because PHP reads both as zero in scientific notation. The attacker needs no password, only such an input. The fixes: password_hash() and password_verify() for passwords, hash_equals() for comparing other secrets, and === everywhere else.

The ecosystem

Composer, PHP's package manager, published its first alpha release in 2013 and its 1.0 in 2016,29 around two decades after Rasmus Lerdorf created PHP in 1994.30 Much older PHP was built with copied vendor code and manual include lines, and it is still running.

Classic PHP is one request, one short lived process. PHP 8.1 added Fibers as a building block for concurrency,31 and libraries such as ReactPHP fill gaps, but long running work and WebSockets usually mean reaching outside the classic request.

WordPress runs on 39.4% of all websites, according to W3Techs.32 Its security record often gets blamed on PHP, so it is worth being precise: Patchstack's figures for 2023 recorded 5,948 new vulnerabilities in the WordPress ecosystem, 97% of them in plugins and 0.2% in WordPress core.33 The risk is the plugin sprawl, and a vibe coded plugin is plugin sprawl with nobody who understands it.

When PHP is the right call

I am not saying never use PHP. Use it when you are extending WordPress, Drupal or Magento, when your team knows modern PHP well, when you use Laravel with current practices, or when hosting constraints demand it. Think hard before choosing it for a new project where you are free to choose, for real time features, or where raw performance matters.

The warning signs, as a checklist

Before you build, or when you are handed a codebase, look for these.

Database

  • No foreign keys defined
  • No indexes beyond primary keys
  • Every column VARCHAR(255) or TEXT
  • Schema changes made by hand, with no migrations
  • SQLite with several servers or many concurrent writers

Architecture

  • All logic in route handlers, with no service layer
  • No background job processing, everything done inside the request
  • Secrets in the code instead of the environment
  • Files of more than 1,000 lines
  • Errors swallowed silently

Framework

  • Streamlit or Gradio serving paying customers
  • Flask without a decided structure for data access
  • FastAPI plus a frontend, glued together with duplicated validation
  • A structure copied from a "build it in ten minutes" tutorial

Testing

  • No tests
  • Tests that only pass on one developer's machine
  • "Testing" means clicking through the interface
  • No continuous integration

Deployment

  • Deploying by FTP, or by SSH and git pull
  • No staging environment
  • No way to roll back

Worked example: reading the checklist

An invented Waterford start up checks its product against the 21 boxes and ticks seven: no foreign keys, no extra indexes, logic in route handlers, swallowed errors, no tests, no continuous integration, no rollback. The count matters less than the pattern. Three of the seven are in testing and deployment, so nothing would catch a regression and nothing could undo one. The work starts there, because every later change depends on it.

How to recover from vibe coding

If you recognise your project, there are three options, and choosing between them is itself an architectural decision.

Choosing a recovery pathA decision flow from top to bottom: add tests first, then refactor in place if the foundations are sound, replace piece by piece if the system is large but separable, and rewrite only if it is fundamentally broken and the team knows why.Write tests around what the system doestodayAre the framework and databasefundamentally right?yesRefactor in place, one subsystem at atimenoCan features be split off behind agateway?yesStrangler fig: replace one feature at atimenoRewrite, only with specifications andan experienced architectKeep the tests running on every change

The flow runs top to bottom. Every path starts with tests, because without them you cannot tell whether a change kept the behaviour. The first question decides whether the foundations stay, the second whether the system can be replaced piece by piece. A rewrite sits at the end of the last branch on purpose.

Refactor in place

This works when the codebase is small enough and the framework is not fundamentally wrong. Add tests for what exists, pull business logic into services, fix the schema step by step and improve one subsystem at a time. Tests are what make refactoring safe.

Replace it piece by piece

Martin Fowler named this the strangler fig approach, after the fig that slowly grows around its host tree. You put the old system behind a gateway, build one feature anew, route that traffic to it, and repeat until the old system is empty. Investment and returns, in his words, "occur gradually and visibly".34

Worked example: a strangler fig at a Cork retailer

An invented Cork sports retailer has a vibe coded Flask shop with checkout, search and accounts tangled together, and it cannot stop trading.

  1. Month one: put a reverse proxy in front of the shop and write tests for the checkout.
  2. Month two: build product search anew and route /search to it. Everything else still goes to Flask.
  3. Months three to five: move accounts, then checkout, one at a time, each behind the same tests.
  4. When the old app receives no traffic, switch it off. At no point was the shop closed.

Rewrite

This is for systems that are fundamentally broken: the wrong database for the workload, no tests, code nobody can maintain. Rewrites have a long and poor reputation. Joel Spolsky called rewriting code from scratch "the single worst strategic mistake that any software company can make", using Netscape as his example.35 Rewrite only if you understand why the first attempt failed, have experienced people making the architectural decisions, have written specifications before any code, and have the budget for the rewrite to take longer than planned.

Architecture is the part you cannot hand over

AI coding assistants cannot ask the questions about scale that nobody put in the prompt, or make the trade offs that decide whether a system lasts. The most expensive code is the code you write twice. The least expensive architectural decision is the one made before the first line.

If you are building something meant to last:

  1. Spend time on architecture before code. Sketch the data model, name the access patterns, and plan for ten times the scale you expect.
  2. Choose boring technology. Dan McKinley's essay of that name puts it well: every company gets only a few "innovation tokens", so spend them on what makes you different, not on the database.36 Rails, Django, Phoenix and Laravel have known scaling paths. A novel framework has unknown ways to fail.
  3. Put experience in early. One senior person making the architectural decisions saves a team fixing them later.
  4. Test without exceptions. Tests are the only way to change code safely.
  5. Use AI for implementation, not design. Let it write the code that implements your architecture. Do not let it choose the architecture.

I have rebuilt vibe coded projects across eCommerce, fintech and SaaS. The patterns are consistent: the wrong framework for the job, missing database indexes, no separation of concerns, and the belief that "it works" means "it scales". If you want to see the same story from the other side of an integration, where a vendor's unreviewed AI code cost my clients real money, read the £35,000 lesson.

The vibe coded prototype that works today becomes the legacy system that blocks you tomorrow. Build for tomorrow.

Footnotes

  1. Andrej Karpathy, post on X introducing "vibe coding", 2 February 2025, https://x.com/karpathy/status/1886192184808149383

  2. Joel Becker, Nate Rush, Elizabeth Barnes and David Rein, Measuring the Impact of Early-2025 AI on Experienced Open-Source Developer Productivity, METR, arXiv:2507.09089, 2025, https://arxiv.org/abs/2507.09089

  3. Hammond Pearce, Baleegh Ahmad, Benjamin Tan, Brendan Dolan-Gavitt and Ramesh Karri, Asleep at the Keyboard? Assessing the Security of GitHub Copilot's Code Contributions, IEEE Symposium on Security and Privacy 2022, arXiv:2108.09293, https://arxiv.org/abs/2108.09293

  4. PostgreSQL Global Development Group, Multicolumn Indexes, PostgreSQL documentation, current version, https://www.postgresql.org/docs/current/indexes-multicolumn.html

  5. SQLite, Appropriate Uses For SQLite, sqlite.org, https://www.sqlite.org/whentouse.html

  6. David Heinemeier Hansson, Rails 8.0: No PaaS Required, Ruby on Rails blog, 7 November 2024, https://rubyonrails.org/2024/11/7/rails-8-no-paas-required

  7. Python Software Foundation, Python support for free threading, Python documentation, https://docs.python.org/3/howto/free-threading-python.html

  8. The Go Team, Go 1.18 is released, The Go Blog, March 2022, https://go.dev/blog/go1.18

  9. Erlang/OTP, Frequently Asked Questions: Introduction, erlang.org, https://www.erlang.org/faq/introduction.html

  10. Snowflake Inc., Caching overview, Streamlit documentation, https://docs.streamlit.io/develop/concepts/architecture/caching

  11. Snowflake Inc., Understanding Streamlit's client-server architecture, Streamlit documentation, https://docs.streamlit.io/develop/concepts/architecture/architecture

  12. Snowflake Inc., Overview of multipage apps, Streamlit documentation, https://docs.streamlit.io/develop/concepts/multipage-apps/overview

  13. Snowflake Inc., st.login, Streamlit API reference, https://docs.streamlit.io/develop/api-reference/user/st.login

  14. Gradio, Sharing Your App: Authentication, Gradio guides, https://www.gradio.app/guides/sharing-your-app

  15. Plotly, Background Callbacks, Dash documentation, https://dash.plotly.com/background-callbacks

  16. Pallets, Using async and await, Flask documentation, https://flask.palletsprojects.com/en/stable/async-await/

  17. Django Software Foundation, Django overview: sites using Django, djangoproject.com, https://www.djangoproject.com/start/overview/

  18. Django Software Foundation, Asynchronous support, Django 5.2 documentation, https://docs.djangoproject.com/en/5.2/topics/async/

  19. Sebastián Ramírez, Concurrency and async / await, FastAPI documentation, https://fastapi.tiangolo.com/async/

  20. Reflex, How Reflex Works, Reflex documentation, https://reflex.dev/docs/advanced-onboarding/how-reflex-works/

  21. Philip Müller, Under Deconstruction: The State of Shopify's Monolith, Shopify Engineering, 16 September 2020, https://shopify.engineering/shopify-monolith

  22. Eileen M. Uchitelle, Upgrading GitHub from Rails 3.2 to 5.2, The GitHub Blog, 2018, https://github.blog/engineering/infrastructure/upgrading-github-from-rails-3-2-to-5-2/

  23. Rails Core Team, Active Record Query Interface: strict_loading, Ruby on Rails Guides, https://guides.rubyonrails.org/active_record_querying.html

  24. WhatsApp, 1 million is so 2011, WhatsApp Blog, January 2012, https://blog.whatsapp.com/1-million-is-so-2011

  25. Gary Rennie, The Road to 2 Million Websocket Connections in Phoenix, Phoenix Blog, 3 November 2015, https://www.phoenixframework.org/blog/the-road-to-2-million-websocket-connections

  26. The PHP Group, PHP 8.0.0 Release Announcement, php.net, November 2020, https://www.php.net/releases/8.0/en.php

  27. Nikita Popov, PHP RFC: Saner string to number comparisons, PHP wiki, implemented in PHP 8.0, https://wiki.php.net/rfc/string_to_number_comparison

  28. The PHP Group, Comparison Operators, PHP manual, https://www.php.net/manual/en/language.operators.comparison.php

  29. Composer, releases 1.0.0-alpha1 (published 3 July 2013) and 1.0.0 (published 5 April 2016), GitHub, https://github.com/composer/composer/releases/tag/1.0.0-alpha1 and https://github.com/composer/composer/releases/tag/1.0.0

  30. The PHP Group, History of PHP, PHP manual, https://www.php.net/manual/en/history.php.php

  31. The PHP Group, PHP 8.1.0 Release Announcement, php.net, November 2021, https://www.php.net/releases/8.1/en.php

  32. W3Techs, Usage statistics and market share of WordPress, accessed November 2026, https://w3techs.com/technologies/details/cm-wordpress

  33. Patchstack, State of WordPress Security in 2024 (covering 2023), patchstack.com, 2024, https://patchstack.com/whitepaper/state-of-wordpress-security-in-2024/

  34. Martin Fowler, Strangler Fig, martinfowler.com, updated 22 August 2024, https://martinfowler.com/bliki/StranglerFigApplication.html

  35. Joel Spolsky, Things You Should Never Do, Part I, Joel on Software, 6 April 2000, https://www.joelonsoftware.com/2000/04/06/things-you-should-never-do-part-i/

  36. Dan McKinley, Choose Boring Technology, mcfunley.com, 2015, https://mcfunley.com/choose-boring-technology

Building something new with AI, or handed a codebase you are not sure about? Bring it to the free workshop and we will look at the data model, the framework and the warning signs together. If we carry on, the first two weeks of work are on me.