Custom PHP Development

Custom PHP Development

Advanced Themer 13 – Feature Update Article

SolarBlu - Advanced Themer 13 Update

Advanced Themer 13: The Big Login, Access & Billing Update

It started with a simple request — "can we change the login background color?" — and turned into the biggest Advanced Themer 13 release yet. A fully branded login screen, real agency-grade access control, and a complete client billing loop powered by Cash App. Here is everything that shipped.

A Login Screen That Wears Your Brand

Branded login screen mockup with logo, field icons, and yellow login button

The login page is the first thing every client sees, and now every piece of it is yours. The new Login Page section under AT13’s Admin settings gives you a background color picker, a background image with tile, single, or stretch-to-fill display modes (a seamless dark-blue ripple tile ships with the plugin), and your own logo above the form — pulled from the Media Library and linked to your home page instead of wordpress.org.

Text gets the same treatment: a color for the links outside the form box, a button color with an automatically derived hover state and outline, and a separate button text color. Then the fun part — a little unlock icon on the Log In button, a person icon beside the Username label, and a key beside Password. A login-only Custom CSS box covers anything the options don’t.

Super Admin: Your Plugin, Not Theirs

Super Admin shield protecting the plugin while client admins run their sites

Clients need real administrator accounts to run their sites — but agency tooling should stay with the agency. AT13 now has a Super Admin layer: the plugin’s pages and settings are visible only to designated Super Admins, while client admins keep full control of their content, users, and everything else. They simply never see AT13 at all, and even hand-crafted settings requests are rejected server-side.

Making someone a Super Admin is one checkbox on their user profile — a checkbox only existing Super Admins can see, so nobody can promote themselves. A built-in email whitelist and a wp-config override round out the safety net so you can never be locked out of your own plugin.

Super Admin Overrides

Two site-wide switches live in the new Overrides section. Hide Plugins removes the Plugins screen for everyone but Super Admins — menu gone, direct URLs blocked, install and deactivate disabled — while plugin auto-updates keep running untouched. Suspend Client Access is the big one, and it deserves its own section.

Suspension & Self-Service Unlock: The Billing Loop

Billing loop diagram: Suspend, Pay by QR, One-Tap Approve, Unlock Code

Nonpayment is awkward. This makes it automatic and fair. Flip one switch and content editing is suspended: Posts, Pages, Media, and Comments step aside for every user except Super Admins. The public website stays fully online the whole time — nothing is deleted, nothing is modified, and unticking the switch restores everything instantly.

Suspended clients are never left guessing. Their dashboard opens with an Account Status box — green and reassuring when all is well, and during suspension it shows exactly what is owed with a Make a Payment button. That button carries the site’s domain and balance straight to the SolarBlu payment portal, where a scannable Cash App QR code and a prefilled form make paying take about a minute.

Then the loop closes itself. The moment a payment is submitted, an email goes out with the details and a one-tap approve link — secure, signed, and phone-friendly, so payments can be approved from anywhere in seconds. Approval mints a single-use unlock code tied to that client’s domain. The client grabs it from their status page, types it into the Account Status box, and the site verifies it and unlocks itself on the spot. The code burns after one use. No back-and-forth, no waiting.

Built-In Invoicing

You do not even have to wait for a client to notice. The Admin screen now has an Invoice section: the payment link for the current site is displayed ready to test or copy, and a Send Invoice form emails the amount due and payment link to whoever needs to pay it — email address saved for next time, subject line prefilled.

The Payment Portal Itself

The Cash App portal got its own upgrade to match: the SolarBlu logo up top, the QR code front and center, a required "website this payment is for" field that tracks every payment to its site, a live status checker, and the unlock code page. On the admin side there is a Site column for matching payments at a glance, plus a tucked-away user management panel — super admins can add or remove portal users, everyone can change their own password, and regular users see none of it.

Little Things That Add Up

Also in this release: the Duplicate Posts/Pages tool is now a proper toggle, the plugin’s main page shows a live feature list with account status at the top, missing tracking scripts show an orange "Not Enabled" nudge instead of quietly saying disabled, and the QR Tracker got its own icon in the admin menu.

Everything here lives in the plugin, so it travels. Activate Advanced Themer 13 on a new site and the branded login, the access control, and the entire billing loop come along for the ride. Build it once, run it everywhere.

Advanced Themer 13 is built and maintained by SolarBlu. Questions or ideas for the next release? Reach out at seth@solarblu.net.

Custom PHP Development

The QR Codes That Actually Made Money — and How We Built the Tracking Into Advanced Themer 13

Field Notes · Marketing Technology

The QR Codes That Actually Made Money — and How We Built the Tracking Into Advanced Themer 13

Everyone prints QR codes. Almost no one can tell you which ones paid off. Here's what changed when we stopped guessing.

Walk down any street and you'll see them. On door hangers. On mailers. On banners at the gas
station and stickers on the back of a work truck. QR codes are everywhere now — the pandemic made
them normal, and every marketer reached for them at once. But here's the question almost nobody in
the room can answer: which of those codes actually made a sale?

Not "how many scans." Scans are easy — every free generator gives you a number. A scan count tells
you a code got opened. It tells you nothing about whether the person who opened it in a driveway in
central Illinois turned into revenue, or whether the identical-looking code on last month's email
blast did all the real work. When you're running door-to-door crews, direct mail, and paid banners
at the same time, "we got 4,000 scans" is not an answer. It's a shrug with a number attached.

The problem was never generating the code. It was proving what it did.

Our codes were never dumb links. Each one carried a campaign-coded URL — tagged by campaign ID,
by location, by usage type (banner, mailer, email), and by the affiliate or D2D/CSR rep who put it in
someone's hand. The link was the easy part. The hard part was catching the visit on the other end,
silently, and stitching it back to the sale — without bouncing the customer through some clunky
redirect that makes the whole thing feel like spam.

That's the difference that turned QR from a novelty into a channel. When a rep works a
neighborhood and a homeowner scans the hanger on their door three days later, we don't just know
"someone scanned." We know which campaign, which territory, which rep, and what
happened next. Do that across a full program and the fog lifts. The campaigns that looked busy but
quietly lost money start to separate from the ones printing revenue — and you pour budget into the
winners instead of spreading it evenly across guesses.

The scan number told us a code was opened. The system told us which code paid the bills.

$2,000,000+ in attributed sales
Not "influenced." Not "part of the mix." Traced — scan to sale — back to the exact codes that produced it.

That number matters less as a brag than as a proof of concept. Without the tracking, that $2M is
just… sales. Unattributable, unrepeatable, invisible to the next planning meeting. With it, every
dollar has a return address: the location, the channel, the campaign, the rep. That's the part you
can run again on purpose.

Bringing it home: baking the tracker into Advanced Themer 13

The early version lived off to the side — a standalone generator and a separate dashboard, held
together by one person's knowledge of how it all fit. That's fine until it isn't. The whole point of
Advanced Themer 13 is that the tools live inside WordPress, where the pages already are. So
that's where the tracker belongs.

The integration is deliberately simple, because simple is what survives contact with a real
marketing team:

  • A per-page toggle. Every page and post gets a "QR Tracker" checkbox right in the editor
    sidebar. Turn it on for your campaign landing pages; leave it off everywhere else. No developer, no
    shortcode surgery — a checkbox.
  • Silent logging. When a tracked page gets a QR visit, it's recorded quietly — campaign,
    location, usage, affiliate, IP, device, referrer — while the visitor just sees your page load
    normally. No redirect hop, no interstitial, nothing that smells like tracking.
  • One dashboard, inside wp-admin. Results by campaign and location, scans and visits side
    by side, right under the Advanced Themer 13 menu. The people running the campaigns can read it
    without logging into a database.
  • Your data, your database. Everything writes to a dedicated tracking database you own —
    not a third-party platform that rents your own numbers back to you and owns the relationship with
    your customer.

That last point is the quiet one, and it's the one that matters most over time. The big QR
platforms are happy to count your scans — as long as the codes point at their servers and the
data lives on their side of the fence. Build it into your own plugin, on your own pages, in
your own database, and you're not renting your results. You own the whole loop: the code, the
landing page, the visit, the sale, and the history that proves what works.

Why this is worth doing right

QR codes aren't going anywhere — they're on more surfaces every year. What's still rare is the
boring, unglamorous discipline of measuring them all the way through to money. Most of the market can
tell you a code was scanned. Very few can hand you a line that reads "this territory, this channel,
this campaign, these dollars." We can. And now that it's built into Advanced Themer 13, any page on
the site can become a tracked landing page with a single click.

That's the whole idea: stop admiring scan counts, and start reading the map that shows you where
the money actually comes from.

Solar Blu Seth
 ·  seth@solarblu.net  ·  solarblu.net  ·  Advanced Themer 13
Custom PHP Development

Advanced Themer 13 Dev Log: One Day, Fourteen Features, and a Three-Plugin Pileup

Advanced Themer 13 Dev Log: One Day, Fourteen Features, and a Three-Plugin Pileup

Behind the scenes of a full day hardening our in-house Astra toolkit — filters, hooks, hotfixes, and one very satisfying bug hunt.


Advanced Themer 13 is our ground-up rebuild of the toolkit we bolt onto every Astra site we ship. Today it grew up a lot. Here's the full engineering log — not the marketing version, the real one.

The philosophy: filter Astra, don't fight it

Everything AT13 does to the front end goes through Astra's own option pipeline. Astra reads every setting through astra_get_option(), and that function runs each value through a dynamic filter: astra_get_option_{$option}. Hook that filter and you own the setting — no template overrides, no CSS specificity wars, no forked theme files that break on update.

That's the pattern behind today's Blog Layout module. The new Layout tab writes plugin options (at13_blog_layout, at13_blog_columns, at13_blog_content, and friends), and a loop registers a closure on the matching Astra filter for each one that's actually set:

add_filter( "astra_get_option_{$astra_opt}", function () use ( $at13_val, $astra_opt ) {
    return in_array( $astra_opt, [ 'blog-grid', 'blog-post-per-page' ], true )
        ? (int) $at13_val
        : $at13_val;
} );

Anything left on default never registers a filter at all, so the Customizer keeps authority until you deliberately take it. Grid/List/Cover layouts, column counts, excerpt vs. full content, hover effects, and opt-in overrides for Astra's blog-post-structure and blog-meta arrays — all of it rides astra_get_option_*. Zero template files touched.

The same trick killed an annoyance: Astra free injects a permanently-disabled "Astra Menu Settings 🔒" button into every item on Appearance → Menus — pure Pro upsell, and it makes third-party panels like Menu Icons look locked. Turns out the entire upsell layer gates on one option. One line in the child theme:

add_filter( 'astra_get_option_ast-disable-upgrade-notices', '__return_false' );

Locks gone. Nothing was ever actually locked.

Header builder, the surgical version

The header picked up real client-facing controls today, each one wired to the markup layer that actually owns it:

  • CTA button after the menu. Appended via the wp_nav_menu_items filter, scoped to theme_location === 'primary', with label, URL, background color, and an open-in-new-tab toggle (rel="noopener noreferrer" included). Astra stretches .menu-link to full nav height, which left the button text riding the top of a tall block — fixed by flex-centering the <li> and forcing a compact box (line-height:1, height:auto, both !important to out-rank Astra's own menu rules).
  • Phone number with SVG icon. The icon is inline SVG using stroke="currentColor", so it inherits the header text color and the hover color for free — no extra HTTP request, no recoloring assets. The whole phone/social row also got wrapped in Astra's .ast-container, so its right edge now aligns to the same content boundary as the menu instead of drifting to the browser edge.
  • Tagline with honest semantics. The show/hide checkbox now overrides Astra's own "Display Site Tagline" customizer setting in both directions via its option filter — checked shows, unchecked hides, and blank text falls back to Settings → General. Previously an empty tagline rendered an invisible empty <p> and looked like a bug.
  • Site title sizing (default 34px, changeable) and per-element show/hide toggles for the phone, all with sane defaults so existing sites don't shift when the plugin updates.

Content controls with per-page escape hatches

Two requests that every client eventually makes: "kill the comment spam" and "hide the page titles." Both are now global toggles with local overrides:

  • Hide Comments (posts) filters comments_open and get_comments_number to zero on singular posts — Astra sees a closed, empty discussion and renders no comments UI at all. No CSS hiding, no orphaned markup.
  • Hide Titles is split into separate Pages and Posts toggles, implemented through Astra's astra_the_title_enabled filter so the <h1> markup is skipped, not display:none'd. Every editor screen gets an "AT13 Title" meta box whose checkbox defaults to checked (hidden); unchecking writes _at13_show_title = 1 post meta and that one page opts back in. Default-on with opt-out means zero clicks for the common case.

Also new: a Duplicate row action on posts and pages — nonce-verified, capability-checked, clones content, excerpt, taxonomies, and all post meta (minus _edit_lock/_edit_last) into a draft titled "(Copy)". Our AT13 per-page flags ride along with the meta, which is exactly what you want when cloning a landing page.

Tracking scripts done right

Three boxes, three injection points, because that's what the vendors actually specify: Header (wp_head at priority 99 — GA4, GTM loader), Body (wp_body_open at priority 1 — GTM's <noscript> frame, textbook placement), and Footer (wp_footer — chat widgets and anything lazy). The sanitizer preserves raw HTML only for users with unfiltered_html; anyone else gets wp_kses_post'd on save, so the feature can't become a stored-XSS vector through a lesser role.

The CSS tab also gained a Mobile CSS box: write plain rules, set a breakpoint (default 720px), and the plugin wraps output in @media (max-width: …) at priority 999 — after Quick Styles, after Astra, so mobile rules win.

The bug hunt: Visual Composer × WooCommerce × PHP 8.5

Mid-session, the VC editor started white-screening. Site fine, one page uneditable. This is where discipline beats guessing: straight to debug.log — all 201 MB of it, bloated by PHP 8.5 deprecation notices from plugins that haven't caught up yet.

The real fatal, timestamped to the minute we hit the editor:

PHP Fatal error: Uncaught TypeError: strpos(): Argument #1 ($haystack)
must be of type string, int given in
woocommerce/src/Internal/ProductAttributes/VisualAttributeTermAdmin.php:290

The autopsy: Visual Composer's editor swaps in an admin screen object whose ID is an integer — and it does it directly, without firing the current_screen action. WooCommerce later runs strpos( $screen->id, 'edit-pa_' ) on admin_enqueue_scripts, assuming a string. On PHP 7 that mismatch was a shrugging warning; on PHP 8+ it's fatal. Three parties, each individually defensible, collectively a white screen.

The fix is a 20-line mu-plugin that normalizes $screen->id to a string on both current_screen and admin_enqueue_scripts at priority 9 — one tick before WooCommerce reads it at 10. No core files patched, survives every update of both plugins, and becomes a no-op the day either vendor fixes their side. We also truncated the log and added error_reporting( E_ALL & ~E_DEPRECATED & ~E_USER_DEPRECATED ) to wp-config so real errors stay findable.

Diagnosis to deployed fix: about ten minutes. That's the entire argument for WP_DEBUG_LOG and reading the actual error instead of bisecting plugins for an afternoon.

The graduation: from "this site's hack" to shippable product

The biggest structural change shipped last. All the Astra wiring used to live in the child theme — which meant every new site needed files hand-copied around. It now lives in the plugin (inc/frontend.php), loaded like this:

add_action( 'after_setup_theme', function () {
    if ( get_template() === 'astra' ) {
        require_once AT13_PATH . 'inc/frontend.php';
    }
}, 5 );

Astra active? Full wiring. Any other theme? Silent no-op — the admin pages still work, nothing fatals. An AT13_FRONTEND_LOADED guard plus a defined('AT13_VERSION') check in the child theme means old child-theme copies can coexist as a fallback without ever double-loading.

New site recipe, final form: Astra → Advanced Themer 13 → bare child theme → drop the mu-plugin hotfix in mu-plugins/. Configure from one menu. Ship.


Custom PHP Development

Behind the Scenes: Advanced Themer 13 — Hooking the SEO Page to Your Pages and Posts

Behind the Scenes: Advanced Themer 13 — Hooking the SEO Page to Your Pages and Posts

This one’s for the WordPress devs. When I rebuilt Advanced Themer’s SEO Options page for version 13, I recoded every field from the legacy v12 plugin into proper registered settings — meta description, keywords, title prefixes and suffixes, the works. The form saved. The options persisted. Everything looked done.

It wasn’t. Every single at13_seo_* option was write-only. The admin page saved them, and nothing on the front end ever read them. No wp_head output, no title filters, nothing. A settings page that faithfully stores your input and then does absolutely nothing with it. (In fairness to v13 — the legacy plugin had the same problem in more than one place. The v12 CSS page rendered its whole Quick Styles form without ever calling register_setting(), so WordPress silently refused to save it at all.)

Here’s how the consumer side got built.

The Per-Page SEO Meta Box

The SEO Options page had a “Per-Page SEO Box” checkbox that was literally a flag pointing at a future build. That build is now real: when the flag is on, Pages and Posts get a meta box with three fields — SEO Title override, Meta Description, and Meta Keywords.

add_action('add_meta_boxes', function () {
    if (!get_option('at13_seo_pp_section_enabled')) return;
    foreach (['page', 'post'] as $type) {
        add_meta_box(
            'at13_seo_meta_box',
            __('SEO — Advanced Themer 13', 'advanced-themer-13'),
            'at13_render_seo_meta_box',
            $type,
            'normal',
            'default'
        );
    }
});

The save handler is standard hardening: nonce check, autosave bail, capability check. One detail worth stealing — empty fields delete the post meta instead of storing empty strings, so the postmeta table doesn’t fill up with blank rows for every page you never customized:

foreach (at13_seo_meta_fields() as $key => $sanitize) {
    $value = isset($_POST[$key]) ? call_user_func($sanitize, wp_unslash($_POST[$key])) : '';
    if ($value === '') {
        delete_post_meta($post_id, $key);
    } else {
        update_post_meta($post_id, $key, $value);
    }
}

The field list itself lives in one small function mapping meta key to sanitize callback, so the render and save sides can never drift out of sync.

Precedence: Per-Page Beats Global

Every front-end output follows the same chain: per-page meta box value → global option (if its enable checkbox is on) → nothing. One helper enforces the gate:

function at13_seo_get_page_meta($key) {
    if (!get_option('at13_seo_pp_section_enabled')) return '';
    if (!is_singular(['post', 'page'])) return '';
    return get_post_meta(get_queried_object_id(), $key, true);
}

Turn the meta box off and every per-page value stops applying instantly — the data stays in postmeta, but nothing reads it. One kill switch, no orphaned behavior.

Titles: document_title_parts, Not wp_title

Title overrides go through the modern document_title_parts filter. The per-page SEO title replaces the core title part, then the prefix/suffix chains stack on top — article prefix/suffix on single posts, category prefix/suffix on category archives, and the global prefix/suffix outermost so it applies everywhere:

add_filter('document_title_parts', function ($parts) {
    $custom = at13_seo_get_page_meta('_at13_seo_title');
    if ($custom) $parts['title'] = $custom;

    $title = isset($parts['title']) ? $parts['title'] : '';

    if (is_singular('post')) {
        if (get_option('at13_seo_article_prefix_enabled') && ($p = get_option('at13_seo_article_prefix'))) $title = $p . ' ' . $title;
        if (get_option('at13_seo_article_suffix_enabled') && ($s = get_option('at13_seo_article_suffix'))) $title = $title . ' ' . $s;
    }
    // ... category + global chains follow the same pattern

    $parts['title'] = trim($title);
    return $parts;
});

Because it’s document_title_parts and not a full pre_get_document_title hijack, the separator and site-name parts that the theme (or another plugin) manages stay intact.

Meta Tags at Priority 1

Description and keywords print on wp_head at priority 1, so they land high in <head> before the pile of styles and scripts. Values get wp_strip_all_tags() plus esc_attr() on the way out — options are sanitized at save time, but output escaping is not optional just because input was clean.

The Fun One: Keyword Deep Links

The SEO page lets you define up to three keyword/link/alt sets. On the front end, each keyword’s first plain-text occurrence in post content gets auto-linked. The regex is where it gets interesting:

$pattern = '/\b(' . preg_quote($keyword, '/') . ')\b(?![^<]*>)(?![^<>]*<\/a>)/i';
$content = preg_replace($pattern, $replace, $content, 1);

Two negative lookaheads do the safety work: the first skips matches sitting inside an HTML tag (so a keyword in an alt="" attribute never gets mangled), the second skips text that’s already inside a link (no nested anchors). The 1 limit on preg_replace keeps it to one link per keyword — auto-linking every occurrence is how content starts reading like 2006 spam. The filter also checks in_the_loop() && is_main_query() so it doesn’t fire on excerpts, widgets, or secondary queries.

The Rest of the Wiring

Menu prefix/suffix bookend nav menus as plain list items via wp_nav_menu_items. The Author Title option swaps WordPress’s default “Author:” archive label through get_the_archive_title_prefix — a filter a lot of devs don’t know exists. Footer HTML outputs on wp_footer, run through wp_kses_post().

And one field deliberately stayed unwired: Deep Link Keywords, a lone text input with no link target and no clear consumer even in the legacy code. Shipping a behavior I’d be guessing at is worse than shipping a field that visibly does nothing — it’s parked until it has a real spec.

The Takeaway

A settings page isn’t a feature. It’s half of one. The other half is the hooks that actually consume what it saves — and it’s shockingly easy, especially when porting legacy code, to build the form, watch it save, and call it done. If you’re auditing an old plugin, grep for get_option() on every option the admin registers. The ones that only ever appear inside the admin form itself? Those are your write-only settings, and your users have been checking boxes that do nothing.

Advanced Themer 13’s SEO page now does what it always looked like it did. Tested on live pages and posts — per-page overrides, title chains, meta tags, keyword links, all firing.

Custom PHP Development

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