Four Real-World PHP, MySQL, and API Problems

Four Real-World PHP, MySQL, and API Problems (and the Fixes)

Most internal admin tools follow the same arc: a PHP script talks to a couple of third-party APIs, writes the results into MySQL, and renders a table so a human can act on the data. It’s a simple shape, but it hides a handful of failure modes that show up in almost every tool built this way. Here are four of them, pulled from a real lead-generation dashboard that pulls business listings via SerpApi and enriches them with the Google Places API — along with the fixes.

1. Your usage counter and the provider’s usage counter will disagree

A lot of API-gated tools track their own call count in a local table — insert a row per call, sum it up, compare against a limit, stop when you hit it. It looks correct, and it is, right up until it isn’t: a request that times out before reaching the provider still gets counted locally even though the provider never saw it; a manual test call made outside the script doesn’t get counted at all; a billing-cycle boundary gets computed with a different timezone than the provider uses. None of these are dramatic bugs. They just quietly accumulate until your local count and the provider’s real count are two different numbers, and your gating logic starts blocking work that the provider would happily still allow.

The fix is to stop maintaining a shadow ledger and ask the provider directly. Most metered APIs expose a free account/usage endpoint specifically for this — SerpApi’s account.json, for example, returns this_month_usage, searches_per_month, and plan_searches_left, and querying it doesn’t count against your quota:

function serpapi_account(): array {
    static $cache = null;
    if ($cache !== null) return $cache;
    $url = 'https://serpapi.com/account.json?api_key=' . urlencode(SERPAPI_KEY);
    $raw = curl_get($url, 6);
    $data = $raw ? json_decode($raw, true) : null;
    if (!is_array($data) || !empty($data['error'])) {
        return $cache = ['ok' => false, 'error' => $data['error'] ?? 'Could not reach account API'];
    }
    $data['ok'] = true;
    return $cache = $data;
}

The local table is still useful as a fallback for the rare moment the account endpoint is unreachable, but it’s no longer the source of truth — the provider is. That one change makes an entire class of “why is this stuck” bugs disappear, because there’s nothing left to drift.

2. Data your code captures but your UI never shows

Enrichment steps tend to grow incrementally — you add a field to the database schema, write it on insert, maybe wire it into a CSV export, and move on. It’s easy to forget the one place that actually matters: whether the field is rendered anywhere a person will look. In this case, a full street address was being fetched from the Google Places API, written to MySQL, and included in the CSV export — but the on-screen leads table never had a column for it. The data existed everywhere except where someone would actually see it.

The fix isn’t just “add a column” — it’s worth checking what your SELECT * payload actually contains versus what your template renders, since that gap is where this bug lives. Once it surfaces, putting the field where it makes sense (nested under the business name, rather than burning a whole new column for one line of text) is usually the better call.

3. Tables grow a column per field until they’re unreadable

Every new enrichment field is tempting to give its own column, and a table that started at five columns ends up at ten, most of them mostly redundant with their neighbors. Category and industry are almost always read together. An area code is meaningless without the town it belongs to. An email address is just another way to reach the same business as the phone number next to it.

Collapsing related fields into a single cell — a bold value with a muted line underneath — recovers most of that width without losing any information:

<td><span class="bdg bl">${e(l.location)}</span> <span class="bdg ba">${e(l.area_code)}</span></td>
<td><span class="bdg bc">${e(l.category)}</span><br><span class="muted">${e(l.industry)}</span></td>
<td>${phoneLink}<br><span class="muted">${emailLink}</span></td>

It’s also worth turning passively-captured data into something clickable. An address that’s already sitting in the database doesn’t need a new API call to become useful — it just needs a link:

function mapsLink(lead) {
  const q = lead.address || `${lead.name} ${lead.location}`;
  return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(q);
}

No geocoding API, no extra request — just a URL pattern Google Maps already understands.

4. Hiding a CSS Grid item can break its sibling’s position

This one isn’t PHP or MySQL, but it’s the kind of bug this style of tool runs into constantly: a two-column CSS Grid layout (grid-template-columns: 310px 1fr) with a sidebar and a results panel. Adding a “hide sidebar” toggle via display: none on the sidebar seems harmless — until the results panel collapses to a sliver.

The reason: a grid item with display: none is removed from grid placement entirely, not just hidden visually. Without an explicit grid-column assigned to the surviving item, the browser re-flows it into the first available track — which, with the sidebar gone, is now the track that used to belong to the sidebar (and which you’ve shrunk to zero width). The fix is to stop relying on auto-placement and pin both items explicitly:

.grid > .sidebar { grid-column: 1; }
.grid > .results { grid-column: 2; }

Now hiding the sidebar only removes its content — its sibling stays exactly where it’s supposed to be, regardless of what’s visible around it.


None of these are exotic problems. They’re the default failure modes of “PHP script talks to an API, writes to MySQL, renders a table” — which is most of what internal tooling actually is. The fixes are small, but they’re the kind of thing that’s much easier to write down once than to rediscover from scratch every time.

SolarBlu
Scroll to Top