DEV Community

Alex
Alex

Posted on

How I Built a Zero-Dependency, Auto-Updating SVG Widget in PHP

When building Local Guides Ranking—a global leaderboard for Google Maps contributors—I wanted to give users a way to show off their stats on their own blogs and GitHub profiles.
The requirement was simple: A live-updating badge that works everywhere. No iframes, no heavy JavaScript, just a clean image.
The solution? Generating dynamic SVGs directly via PHP and serving them as standard images. Here is a breakdown of how I built it, the hurdles I faced, and how I solved them.

1. Generating SVGs on the Fly

Instead of using an external library to draw images (like GD or ImageMagick), I opted to output raw SVG code directly from a PHP script. SVGs are essentially just XML, which makes them incredibly easy to manipulate with standard string interpolation.
By sending an image/svg+xml header, the browser treats the PHP endpoint exactly like a static image file.

// Strict MIME type to ensure the browser treats it as an image
header('Content-Type: image/svg+xml');

// Generate raw SVG as a string using Heredoc syntax
$svg_content = '<?xml version="1.0" encoding="UTF-8"?>' . "\n" . <<<SVG
<svg xmlns="http://www.w3.org/2000/svg" width="350" height="130" viewBox="0 0 350 130">
    <defs>
        <!-- The drop shadow ensures perfect contrast on both light and dark blogs -->
        <filter id="shadow" x="-10%" y="-10%" width="120%" height="120%">
            <feDropShadow dx="0" dy="4" stdDeviation="6" flood-opacity="0.08"/>
        </filter>
    </defs>

    <rect width="340" height="120" x="5" y="5" rx="12" fill="#ffffff" filter="url(#shadow)" stroke="#e8eaed" stroke-width="1"/>

    <!-- Dynamic data injected directly into the SVG -->
    <text x="25" y="31" font-family="-apple-system, sans-serif" font-size="18" font-weight="bold" fill="#202124">{$name_default}</text>

    <!-- Additional SVG elements... -->
</svg>
SVG;

echo $svg_content;
exit;
Enter fullscreen mode Exit fullscreen mode

I added a drop shadow (feDropShadow) directly into the SVG definitions. This ensures the badge looks sleek and pops out on 95% of websites, regardless of whether their background is white, gray, or colorful.

2. The International String Truncation Problem (CJK Characters)

Since Local Guides come from all over the world, I quickly ran into a layout-breaking bug. Unlike standard HTML/CSS, SVGs do not natively support text-overflow: ellipsis. If a username is too long, it simply bleeds out of the SVG canvas.
My first instinct was to use mb_strlen() to truncate the name after 20 characters. But I hadn't accounted for Asian characters (Chinese, Japanese, Korean). A single "Full-Width" character takes up twice the visual space of a Latin letter.
The fix was utilizing PHP's powerful visual string width functions:

$raw_name = $profile['profile_name'];

// mb_strwidth() measures the visual width (Asian characters = 2, Latin = 1)
// mb_strimwidth() truncates cleanly based on this visual width
$name_default = mb_strwidth($raw_name, 'UTF-8') > 28
    ? mb_strimwidth($raw_name, 0, 28, '…', 'UTF-8') 
    : $raw_name;

// Mandatory escaping for valid XML
$name_default = htmlspecialchars($name_default, ENT_XML1, 'UTF-8');
Enter fullscreen mode Exit fullscreen mode

mb_strwidth() counts standard letters as 1 and full-width characters as 2. Combined with mb_strimwidth(), it perfectly truncates the string based on visual space rather than character count, keeping the widget layout flawless.

3. High-Performance Caching

Rendering the widget requires querying the database, calculating global ranks, and formatting metrics. Doing this on every single image load across hundreds of external blogs would instantly kill my server.
To prevent this, I built a language-specific caching layer.

$svg_content = '';
$needs_recalc = true;

// 1. Check if we have a cached widget in the database
if ($cache_data) {
    // 2. Cache validation: Is the SVG cache newer than the latest profile update?
    if (strtotime($cache_data['calculated_at']) >= strtotime($profile['updated'])) {
        $svg_content = $cache_data['svg_content'];
        $needs_recalc = false;
    }
}

// 3. Only if necessary: Recalculate and save the SVG
if ($needs_recalc) {
    $svg_content = generate_svg($profile); // SVG logic

    $stmt_upsert = $conn->prepare("
        INSERT INTO widget_cache (profile_id, widget_type, svg_content, calculated_at) 
        VALUES (?, ?, ?, NOW()) 
        ON DUPLICATE KEY UPDATE svg_content = VALUES(svg_content), calculated_at = NOW()
    ");
    $stmt_upsert->execute();
}

// 4. Allow caching on browser and CDN level
header('Cache-Control: public, max-age=3600, s-maxage=3600');
echo $svg_content;
Enter fullscreen mode Exit fullscreen mode

The widget only recalculates the SVG if the user's underlying profile data has actually changed. Otherwise, it instantly serves the cached SVG string from the database (0ms DB calculation load). I also added Cache-Control: public, max-age=3600 to the headers so browsers cache the image locally for 1 hour.

4. The Final Boss: Aggressive Image Proxies

Ironically, while the widget works perfectly on GitHub profiles, WordPress, and standard HTML blogs, I couldn't embed the live version in this very Dev.to article. Why?

Platforms like Dev.to use aggressive image CDNs and proxies (like Cloudinary) that don't just pass the image through to the user—they actively download, parse, and re-encode it. Dynamic SVGs containing advanced filters (like my drop shadow) or lacking extremely strict XML document declarations can sometimes crash these strict server-side parsers. When that happens, the CDN caches a 404 error, and the image breaks.

It’s a great reminder that when building dynamic media, you aren't just dealing with web browsers (which are very forgiving), but also with rigid server-side bots.

The Final Result

By combining PHP, raw SVG manipulation, and an aggressive caching strategy, users get a lightning-fast widget they can embed anywhere using standard Markdown or HTML.

Because of the CDN proxy quirk mentioned above, here is a static screenshot of what the widget looks like in the wild:

Google Local Guides Ranking Badge of Herve Andrieu

Building this feature was a massive learning experience in performance optimization and edge-case handling. If you have ever built dynamic images for your app, let me know your approach in the comments!

Top comments (1)

Collapse
 
suppdevbot profile image
Info Comment hidden by post author - thread only accessible via permalink
DEV SUPPORTS •
You need to verify your account.
Enter fullscreen mode Exit fullscreen mode

tr.ee/dev-to

Some comments have been hidden by the post's author - find out more