Creating personalized event banners can help promote engagement on platforms like LinkedIn, Instagram, or Twitter.
Instead of manually editing each banner, you can automatically generate them using PHP and GD Library, allowing each attendee to download their custom “I’m Attending” banner instantly.
In this blog, we’ll walk through a real working PHP example that generates a banner using a base PNG template, adds the attendee’s profile photo, and overlays their name in a clean, centered layout.
Requirements
Before getting started, make sure you have:
- PHP installed with GD library enabled (extension=gd in php.ini).
- A base template image (e.g., attending-share20.png).
- A TrueType font file (font.ttf) in the same directory.
- A folder named generated/ with write permissions (chmod 777 generated/).
Complete PHP Code
<?php
$designation = "";
$leadcode = $_POST['leadcode'];
$email = $_POST['email'];
$batch_id = $_POST['batch_id'];
$stu_name = $_POST['stu_name'];
$image = $_FILES['user_image']['tmp_name'];
$imgp = $_POST['codeb'];
// Load base banner image
$base = imagecreatefrompng($imgp);
// Font files
$font_bold = "font.ttf";
$font_regular = "font.ttf";
if (!file_exists($font_bold) || !file_exists($font_regular)) {
die("Font files missing!");
}
// Define colors
$red = imagecolorallocate($base, 33, 114, 255);
$black = imagecolorallocate($base, 0, 0, 0);
$white = imagecolorallocate($base, 255, 255, 255);
// Add user profile photo
$userImg = imagecreatefromstring(file_get_contents($image));
$userImg = imagescale($userImg, 210, 210);
imagecopy($base, $userImg, 200, 255, 0, 0, 210, 210);
// Center the student's name horizontally
$name_box = imagettfbbox(16, 0, $font_bold, $stu_name);
$text_width = $name_box[2] - $name_box[0];
$image_width = imagesx($base);
$x_center = ($image_width - $text_width) / 2;
// Add student name text
imagettftext($base, 16, 0, $x_center, 500, $white, $font_bold, $stu_name);
// Save the generated image
$fileName = "generated/" . $leadcode . ".png";
imagepng($base, $fileName);
// Clean up memory
imagedestroy($base);
imagedestroy($userImg);
// Return file path
$generatedImage = $fileName;
echo "Banner created successfully: <a href='$generatedImage' target='_blank'>$generatedImage</a>";
?>
Code Explanation
| Section | Description |
|---|---|
| imagecreatefrompng() | Loads the base template image (your event banner). |
| Font Validation | Checks that required font files exist before adding text. |
| imagecolorallocate() | Defines the RGB colors used for text or shapes. |
| imagecreatefromstring() | Converts uploaded profile photo into an image resource. |
| imagescale() | Resizes the uploaded image to fit your banner dimensions. |
| imagecopy() | Merges the profile picture onto the base image at a defined position. |
| imagettftext() | Adds the student’s name text centered on the banner using TrueType fonts. |
| imagepng() | Saves the generated image file to the “generated” directory. |
Sample Output
+------------------------------------------------+
| [ Event Banner Background Image ] |
| |
| [User’s Profile Image] |
| |
| John Doe |
+------------------------------------------------+
Result file:
📁 /generated/LEAD123.png
How to Use in Your Website
You can create a small form like this:
<form method="POST" enctype="multipart/form-data" action="generate_banner.php">
<input type="text" name="stu_name" placeholder="Enter your name" required><br>
<input type="file" name="user_image" accept="image/*" required><br>
<input type="hidden" name="leadcode" value="LEAD123">
<input type="hidden" name="codeb" value="attending-share20.png">
<button type="submit">Generate My Banner</button>
</form>
Real-World Use Cases
- 🎓 Education: “I’m Attending” banners for webinars or workshops.
- 🧑💼 Corporate: Event attendee badges.
- 🎉 Marketing: Personalized shareable posters for participants.
- 🏆 Competitions: Auto-generated certificates or announcement images.
Why “I’m Attending” Banners Drive Real Event Registrations
Personalized “I’m Attending” banners are one of the highest-ROI viral marketing mechanics for events, webinars, and conferences. The psychology is simple: people share things that make them look good, and a banner with their own photo and name on a professional event template is exactly that.
When done right, this single feature can drive 15-30% additional registrations through organic social sharing — each attendee becomes a micro-promoter the moment they download and post their personalized banner.
The original tutorial covers the basic mechanics correctly: load a template, overlay a photo, center some text, save the result. But the code as published has serious production problems that will break or get exploited the moment real users interact with it:
- No file type validation — accepts any uploaded file, including PHP scripts disguised as images
- No path sanitization — $leadcode goes directly into a file path, opening directory traversal
- No error handling — any malformed upload causes a fatal error with no graceful fallback
- Square photo crop only — most modern banner designs need circular avatars
- No image format detection — imagecreatefromstring() fails silently on corrupted uploads
- No EXIF orientation handling — phone photos often appear sideways or upside down
- No file size limits — a 50MB upload will exhaust memory and crash the GD process
- No rate limiting — the endpoint can be hammered to fill your disk with garbage
- Synchronous single-file processing — no batch generation, no caching, no cleanup
This guide rebuilds the entire system as production-grade code: secure, validated, with circular avatars, proper error handling, EXIF correction, multiple template support, and a complete frontend.
Part 1 — Understanding the GD Library Pipeline
Before writing the production code, understand exactly what each GD function does and why ordering matters:
THE COMPLETE GD BANNER GENERATION PIPELINE ───────────────────────────────────────────────────────────────────────── 1. Validate inputs (name, file type, file size) ← Security gate 2. Load base template imagecreatefrompng() 3. Load uploaded photo imagecreatefromstring() 4. Fix EXIF orientation (phone photos) exif_read_data() + imagerotate() 5. Crop photo to square (if not already) imagecrop() 6. Resize photo to target dimensions imagecopyresampled() ← NOT imagescale() 7. Create circular mask (optional) imagefilledellipse() + alpha blending 8. Composite photo onto template imagecopy() / imagecopymerge() 9. Calculate text position (centering) imagettfbbox() 10. Render text with anti-aliasing imagettftext() 11. Apply any badges/decorations imagecopy() for overlay elements 12. Save with sanitized filename imagepng() / imagejpeg() 13. Clean up memory imagedestroy() on EVERY resource 14. Return shareable URL ─────────────────────────────────────────────────────────────────────────
Why imagecopyresampled() Instead of imagescale()
The original article uses imagescale(), which is fast but produces lower-quality results — it uses nearest-neighbor or simple interpolation by default. imagecopyresampled() uses proper resampling and produces noticeably sharper results for profile photos:
<?php
// ❌ Original approach — lower quality, especially when downscaling
$userImg = imagescale($userImg, 210, 210);
// ✅ Production approach — proper resampling
$resized = imagecreatetruecolor(210, 210);
imagecopyresampled(
$resized, $userImg,
0, 0, 0, 0, // Destination and source X,Y
210, 210, // Destination width, height
imagesx($userImg), imagesy($userImg) // Source width, height
);
Part 2 — Complete Secure Banner Generator Class
This is a full rewrite as a production class with validation, error handling, circular avatars, EXIF correction, and security hardening throughout.
<?php
declare(strict_types=1);
/**
* EventBannerGenerator
*
* Production-grade "I'm Attending" banner generator using GD library.
*
* Security features:
* - MIME type validation via finfo (not just extension)
* - File size limits
* - Sanitized output filenames (no path traversal)
* - Image dimension limits (prevent memory exhaustion attacks)
* - Proper error handling at every GD call
*
* Quality features:
* - EXIF orientation correction
* - High-quality resampling
* - Circular avatar cropping with anti-aliased edges
* - Multi-line text wrapping
* - Font fallback chain
*/
class EventBannerGenerator {
// ── Configuration ─────────────────────────────────────────────────────
private const MAX_UPLOAD_SIZE = 8 * 1024 * 1024; // 8MB
private const MAX_IMAGE_DIMENSION = 4000; // Prevent decompression bomb attacks
private const ALLOWED_MIME_TYPES = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/webp' => 'webp',
];
private string $templatePath;
private string $outputDir;
private string $fontPath;
private array $errors = [];
public function __construct(
string $templatePath,
string $outputDir,
string $fontPath
) {
$this->templatePath = $templatePath;
$this->outputDir = rtrim($outputDir, '/');
$this->fontPath = $fontPath;
$this->validateEnvironment();
}
/**
* Verify GD extension, template, and font files exist before processing.
*/
private function validateEnvironment(): void {
if (!extension_loaded('gd')) {
throw new \RuntimeException('GD extension is not installed. Run: sudo apt install php-gd');
}
if (!file_exists($this->templatePath)) {
throw new \RuntimeException("Template image not found: {$this->templatePath}");
}
if (!file_exists($this->fontPath)) {
throw new \RuntimeException("Font file not found: {$this->fontPath}");
}
if (!is_dir($this->outputDir)) {
if (!mkdir($this->outputDir, 0755, true) && !is_dir($this->outputDir)) {
throw new \RuntimeException("Cannot create output directory: {$this->outputDir}");
}
}
if (!is_writable($this->outputDir)) {
throw new \RuntimeException("Output directory is not writable: {$this->outputDir}");
}
}
/**
* Main entry point — generate a personalized banner.
*
* @param array $uploadedFile $_FILES['user_image'] array
* @param string $attendeeName The attendee's display name
* @param string $uniqueId Unique identifier (lead code, user ID, etc.)
* @param array $options Optional positioning/styling overrides
* @return array ['success' => bool, 'path' => string, 'url' => string, 'errors' => array]
*/
public function generate(
array $uploadedFile,
string $attendeeName,
string $uniqueId,
array $options = []
): array {
$this->errors = [];
// ── Step 1: Validate the uploaded file ─────────────────────────────
$validatedFile = $this->validateUpload($uploadedFile);
if ($validatedFile === null) {
return $this->failureResult();
}
// ── Step 2: Sanitize the attendee name ─────────────────────────────
$cleanName = $this->sanitizeName($attendeeName);
if ($cleanName === '') {
$this->errors[] = 'Name is required and must contain valid characters.';
return $this->failureResult();
}
// ── Step 3: Sanitize the unique ID for safe filename use ───────────
$safeId = $this->sanitizeId($uniqueId);
if ($safeId === '') {
$this->errors[] = 'Invalid identifier provided.';
return $this->failureResult();
}
// ── Step 4: Load and validate the base template ───────────────────
$base = $this->loadTemplate();
if ($base === false) {
return $this->failureResult();
}
// ── Step 5: Load and process the user's photo ──────────────────────
$userPhoto = $this->loadAndProcessPhoto($validatedFile['tmp_path'], $validatedFile['mime']);
if ($userPhoto === false) {
imagedestroy($base);
return $this->failureResult();
}
// ── Step 6: Composite the photo onto the banner ────────────────────
$photoOptions = array_merge([
'x' => 200,
'y' => 255,
'size' => 210,
'circular' => true,
'border' => true,
'border_color' => [255, 255, 255],
'border_width' => 4,
], $options['photo'] ?? []);
$this->compositePhoto($base, $userPhoto, $photoOptions);
imagedestroy($userPhoto);
// ── Step 7: Render the attendee name ────────────────────────────────
$textOptions = array_merge([
'font_size' => 16,
'y' => 500,
'color' => [255, 255, 255],
'max_width' => imagesx($base) - 80,
], $options['text'] ?? []);
$this->renderText($base, $cleanName, $textOptions);
// ── Step 8: Save the generated banner ───────────────────────────────
$filename = $safeId . '_' . substr(md5(uniqid('', true)), 0, 8) . '.png';
$filePath = $this->outputDir . '/' . $filename;
$saved = imagepng($base, $filePath, 6); // Compression level 6 (balanced)
imagedestroy($base);
if (!$saved) {
$this->errors[] = 'Failed to save the generated banner.';
return $this->failureResult();
}
chmod($filePath, 0644);
return [
'success' => true,
'path' => $filePath,
'url' => $this->pathToUrl($filePath),
'errors' => [],
];
}
/**
* Validate uploaded file: presence, errors, size, and ACTUAL mime type
* (not the client-supplied Content-Type header, which is trivially spoofed).
*/
private function validateUpload(array $file): ?array {
if (empty($file['tmp_name']) || !is_uploaded_file($file['tmp_name'])) {
$this->errors[] = 'No valid file was uploaded.';
return null;
}
if ($file['error'] !== UPLOAD_ERR_OK) {
$this->errors[] = $this->getUploadErrorMessage($file['error']);
return null;
}
if ($file['size'] > self::MAX_UPLOAD_SIZE) {
$maxMb = self::MAX_UPLOAD_SIZE / 1024 / 1024;
$this->errors[] = "File too large. Maximum size is {$maxMb}MB.";
return null;
}
if ($file['size'] === 0) {
$this->errors[] = 'Uploaded file is empty.';
return null;
}
// ── Critical security check: verify ACTUAL file content type ───────
// Never trust $_FILES['type'] — it's just the client's claim
$finfo = finfo_open(FILEINFO_MIME_TYPE);
$realMime = finfo_file($finfo, $file['tmp_name']);
finfo_close($finfo);
if (!array_key_exists($realMime, self::ALLOWED_MIME_TYPES)) {
$this->errors[] = "Invalid file type detected ({$realMime}). " .
"Only JPEG, PNG, and WebP images are allowed.";
return null;
}
// ── Verify it's actually a valid, readable image (catches polyglot files) ───
$imageInfo = @getimagesize($file['tmp_name']);
if ($imageInfo === false) {
$this->errors[] = 'File is not a valid image or is corrupted.';
return null;
}
[$width, $height] = $imageInfo;
// ── Prevent decompression bomb attacks (tiny file, huge dimensions) ──
if ($width > self::MAX_IMAGE_DIMENSION || $height > self::MAX_IMAGE_DIMENSION) {
$this->errors[] = "Image dimensions too large (max " .
self::MAX_IMAGE_DIMENSION . "px per side).";
return null;
}
if ($width < 50 || $height < 50) {
$this->errors[] = 'Image is too small. Minimum 50×50 pixels required.';
return null;
}
return [
'tmp_path' => $file['tmp_name'],
'mime' => $realMime,
'width' => $width,
'height' => $height,
];
}
/**
* Sanitize the attendee name: strip tags, limit length, allow only
* safe characters for display (letters, numbers, spaces, basic punctuation).
*/
private function sanitizeName(string $name): string {
$name = trim(strip_tags($name));
$name = preg_replace('/[\x00-\x1F\x7F]/', '', $name); // Strip control chars
$name = mb_substr($name, 0, 60, 'UTF-8'); // Reasonable display limit
return $name;
}
/**
* Sanitize an identifier for safe use in filenames.
* Prevents directory traversal (../../etc/passwd style attacks).
*/
private function sanitizeId(string $id): string {
// Only allow alphanumeric, hyphens, underscores
$clean = preg_replace('/[^a-zA-Z0-9_-]/', '', $id);
$clean = substr($clean, 0, 64); // Reasonable length limit
return $clean;
}
/**
* Load the base template image with format auto-detection.
*/
private function loadTemplate(): \GdImage|false {
$imageInfo = @getimagesize($this->templatePath);
if ($imageInfo === false) {
$this->errors[] = 'Could not read template image dimensions.';
return false;
}
$mimeType = $imageInfo['mime'];
$image = match ($mimeType) {
'image/png' => @imagecreatefrompng($this->templatePath),
'image/jpeg' => @imagecreatefromjpeg($this->templatePath),
'image/webp' => @imagecreatefromwebp($this->templatePath),
default => false,
};
if ($image === false) {
$this->errors[] = "Failed to load template image (format: {$mimeType}).";
return false;
}
// Preserve transparency for PNG templates
imagesavealpha($image, true);
imagealphablending($image, true);
return $image;
}
/**
* Load the user's uploaded photo, fix EXIF orientation,
* and prepare it as a square truecolor image.
*/
private function loadAndProcessPhoto(string $path, string $mime): \GdImage|false {
$image = match ($mime) {
'image/jpeg' => @imagecreatefromjpeg($path),
'image/png' => @imagecreatefrompng($path),
'image/webp' => @imagecreatefromwebp($path),
default => false,
};
if ($image === false) {
$this->errors[] = 'Failed to process the uploaded photo.';
return false;
}
// ── Fix EXIF orientation (critical for phone camera photos) ────────
if ($mime === 'image/jpeg' && function_exists('exif_read_data')) {
$image = $this->correctOrientation($image, $path);
}
// ── Convert to truecolor for consistent processing ─────────────────
if (!imageistruecolor($image)) {
$truecolor = imagecreatetruecolor(imagesx($image), imagesy($image));
imagecopy($truecolor, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
imagedestroy($image);
$image = $truecolor;
}
// ── Crop to square (center crop) before resizing ────────────────────
$image = $this->cropToSquare($image);
return $image;
}
/**
* Correct image orientation based on EXIF data.
* Phone cameras often save photos with rotation metadata instead
* of physically rotating the pixel data.
*/
private function correctOrientation(\GdImage $image, string $path): \GdImage {
$exif = @exif_read_data($path);
if (!$exif || !isset($exif['Orientation'])) {
return $image;
}
$rotated = match ((int) $exif['Orientation']) {
3 => imagerotate($image, 180, 0),
6 => imagerotate($image, -90, 0),
8 => imagerotate($image, 90, 0),
default => $image,
};
if ($rotated !== $image && $rotated !== false) {
imagedestroy($image);
return $rotated;
}
return $image;
}
/**
* Center-crop an image to a square aspect ratio.
* Prevents distortion when the uploaded photo isn't already square.
*/
private function cropToSquare(\GdImage $image): \GdImage {
$width = imagesx($image);
$height = imagesy($image);
$size = min($width, $height);
$srcX = (int) (($width - $size) / 2);
$srcY = (int) (($height - $size) / 2);
$cropped = imagecreatetruecolor($size, $size);
imagecopy($cropped, $image, 0, 0, $srcX, $srcY, $size, $size);
imagedestroy($image);
return $cropped;
}
/**
* Composite the user's photo onto the base banner image.
* Supports circular cropping with anti-aliased edges and an optional border ring.
*/
private function compositePhoto(\GdImage $base, \GdImage $photo, array $options): void {
$size = $options['size'];
$x = $options['x'];
$y = $options['y'];
// ── Resize photo to target size using high-quality resampling ──────
$resized = imagecreatetruecolor($size, $size);
imagecopyresampled(
$resized, $photo,
0, 0, 0, 0,
$size, $size,
imagesx($photo), imagesy($photo)
);
if (!$options['circular']) {
// Simple square composite
imagecopy($base, $resized, $x, $y, 0, 0, $size, $size);
imagedestroy($resized);
return;
}
// ── Circular crop with anti-aliasing ────────────────────────────────
$circular = imagecreatetruecolor($size, $size);
$transparent = imagecolorallocatealpha($circular, 0, 0, 0, 127);
imagefill($circular, 0, 0, $transparent);
imagesavealpha($circular, true);
// Use imagecopymerge with a circular mask built pixel-by-pixel
// for clean anti-aliased edges (better than imagefilledellipse alone)
$radius = $size / 2;
for ($yi = 0; $yi < $size; $yi++) {
for ($xi = 0; $xi < $size; $xi++) {
$dx = $xi - $radius;
$dy = $yi - $radius;
$distance = sqrt($dx * $dx + $dy * $dy);
if ($distance <= $radius - 1) {
// Fully inside circle — opaque
$color = imagecolorat($resized, $xi, $yi);
imagesetpixel($circular, $xi, $yi, $color);
} elseif ($distance <= $radius + 1) {
// Edge pixel — anti-alias by blending alpha
$alpha = (int) (127 * ($distance - ($radius - 1)) / 2);
$alpha = max(0, min(127, $alpha));
$rgb = imagecolorat($resized, $xi, $yi);
$r = ($rgb >> 16) & 0xFF;
$g = ($rgb >> 8) & 0xFF;
$b = $rgb & 0xFF;
$blendedColor = imagecolorallocatealpha($circular, $r, $g, $b, $alpha);
imagesetpixel($circular, $xi, $yi, $blendedColor);
}
// Outside radius+1: leave fully transparent
}
}
imagedestroy($resized);
// ── Draw border ring (optional) ─────────────────────────────────────
if ($options['border']) {
[$br, $bg, $bb] = $options['border_color'];
$borderColor = imagecolorallocate($circular, $br, $bg, $bb);
$borderWidth = $options['border_width'];
for ($w = 0; $w < $borderWidth; $w++) {
imageellipse($circular, (int)$radius, (int)$radius,
$size - 1 - ($w * 2), $size - 1 - ($w * 2), $borderColor);
}
}
// ── Composite the circular photo onto the base banner ──────────────
imagecopy($base, $circular, $x, $y, 0, 0, $size, $size);
imagedestroy($circular);
}
/**
* Render attendee name with automatic centering and multi-line wrapping
* if the name is too long to fit on one line.
*/
private function renderText(\GdImage $base, string $text, array $options): void {
$fontSize = $options['font_size'];
$color = imagecolorallocate($base, ...$options['color']);
$maxWidth = $options['max_width'];
$imageWidth = imagesx($base);
// ── Check if text fits on one line ──────────────────────────────────
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $text);
$textWidth = $bbox[2] - $bbox[0];
if ($textWidth <= $maxWidth) {
// Single line — center horizontally
$x = (int) (($imageWidth - $textWidth) / 2);
imagettftext($base, $fontSize, 0, $x, $options['y'], $color, $this->fontPath, $text);
return;
}
// ── Text too long — wrap to multiple lines ──────────────────────────
$lines = $this->wrapText($text, $fontSize, $maxWidth);
$lineHeight = $fontSize * 1.4;
$startY = $options['y'] - (count($lines) - 1) * $lineHeight / 2;
foreach ($lines as $i => $line) {
$lineBbox = imagettfbbox($fontSize, 0, $this->fontPath, $line);
$lineWidth = $lineBbox[2] - $lineBbox[0];
$x = (int) (($imageWidth - $lineWidth) / 2);
$y = (int) ($startY + $i * $lineHeight);
imagettftext($base, $fontSize, 0, $x, $y, $color, $this->fontPath, $line);
}
}
/**
* Word-wrap text to fit within a maximum pixel width.
*/
private function wrapText(string $text, int $fontSize, int $maxWidth): array {
$words = explode(' ', $text);
$lines = [];
$currentLine = '';
foreach ($words as $word) {
$testLine = $currentLine === '' ? $word : $currentLine . ' ' . $word;
$bbox = imagettfbbox($fontSize, 0, $this->fontPath, $testLine);
$width = $bbox[2] - $bbox[0];
if ($width > $maxWidth && $currentLine !== '') {
$lines[] = $currentLine;
$currentLine = $word;
} else {
$currentLine = $testLine;
}
}
if ($currentLine !== '') {
$lines[] = $currentLine;
}
return $lines ?: [$text];
}
private function getUploadErrorMessage(int $errorCode): string {
return match ($errorCode) {
UPLOAD_ERR_INI_SIZE => 'File exceeds the server upload size limit.',
UPLOAD_ERR_FORM_SIZE => 'File exceeds the form upload size limit.',
UPLOAD_ERR_PARTIAL => 'File was only partially uploaded. Please try again.',
UPLOAD_ERR_NO_FILE => 'No file was uploaded.',
UPLOAD_ERR_NO_TMP_DIR => 'Server configuration error: missing temp directory.',
UPLOAD_ERR_CANT_WRITE => 'Server configuration error: failed to write file.',
UPLOAD_ERR_EXTENSION => 'File upload blocked by a server extension.',
default => 'Unknown upload error.',
};
}
private function pathToUrl(string $path): string {
// Adjust this based on your server's document root configuration
$docRoot = $_SERVER['DOCUMENT_ROOT'] ?? '';
if ($docRoot && str_starts_with($path, $docRoot)) {
return substr($path, strlen($docRoot));
}
return $path;
}
private function failureResult(): array {
return [
'success' => false,
'path' => '',
'url' => '',
'errors' => $this->errors,
];
}
public function getErrors(): array {
return $this->errors;
}
}
Part 3 — The Secure Endpoint (Replacing the Original’s Vulnerable Script)
<?php
declare(strict_types=1);
/**
* generate_banner.php
* Secure endpoint for generating personalized event banners.
*/
require_once 'EventBannerGenerator.php';
// ── Basic rate limiting (prevent abuse) ──────────────────────────────────────
session_start();
$rateLimitKey = 'banner_gen_' . md5($_SERVER['REMOTE_ADDR'] ?? 'unknown');
$rateLimitFile = sys_get_temp_dir() . '/' . $rateLimitKey . '.json';
$requests = [];
if (file_exists($rateLimitFile)) {
$requests = json_decode(file_get_contents($rateLimitFile), true) ?? [];
}
$requests = array_filter($requests, fn($ts) => (time() - $ts) < 60); // 1-minute window
if (count($requests) >= 5) { // Max 5 banner generations per minute per IP
http_response_code(429);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Too many requests. Please wait a moment.']]);
exit;
}
$requests[] = time();
file_put_contents($rateLimitFile, json_encode(array_values($requests)), LOCK_EX);
// ── Validate request method ───────────────────────────────────────────────────
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Method not allowed.']]);
exit;
}
// ── Whitelist allowed templates — NEVER let users pass arbitrary file paths ──
// This fixes the original article's critical vulnerability: $_POST['codeb']
// was used directly as a file path, allowing path traversal attacks.
const ALLOWED_TEMPLATES = [
'webinar2025' => __DIR__ . '/templates/attending-webinar-2025.png',
'conference' => __DIR__ . '/templates/attending-conference.png',
'workshop' => __DIR__ . '/templates/attending-workshop.png',
];
$templateKey = $_POST['template'] ?? '';
if (!array_key_exists($templateKey, ALLOWED_TEMPLATES)) {
http_response_code(400);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Invalid template selected.']]);
exit;
}
// ── Validate and sanitize all other inputs ────────────────────────────────────
$attendeeName = trim((string)($_POST['stu_name'] ?? ''));
$leadCode = trim((string)($_POST['leadcode'] ?? ''));
$email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
if ($attendeeName === '') {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Name is required.']]);
exit;
}
if ($leadCode === '') {
// Generate a safe random ID if not provided
$leadCode = 'attendee_' . bin2hex(random_bytes(8));
}
if (empty($_FILES['user_image'])) {
http_response_code(422);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Please upload a profile photo.']]);
exit;
}
// ── Generate the banner ────────────────────────────────────────────────────────
try {
$generator = new EventBannerGenerator(
templatePath: ALLOWED_TEMPLATES[$templateKey],
outputDir: __DIR__ . '/generated',
fontPath: __DIR__ . '/fonts/Inter-Bold.ttf'
);
$result = $generator->generate(
uploadedFile: $_FILES['user_image'],
attendeeName: $attendeeName,
uniqueId: $leadCode,
options: [
'photo' => [
'x' => 200,
'y' => 255,
'size' => 210,
'circular' => true,
'border' => true,
],
'text' => [
'font_size' => 18,
'y' => 510,
'color' => [255, 255, 255],
],
]
);
} catch (\RuntimeException $e) {
error_log('Banner generation error: ' . $e->getMessage());
http_response_code(500);
header('Content-Type: application/json');
echo json_encode(['success' => false, 'errors' => ['Server error. Please try again later.']]);
exit;
}
header('Content-Type: application/json');
if ($result['success']) {
// Optional: Log successful generation for analytics
error_log("Banner generated: {$leadCode} | {$attendeeName} | {$result['url']}");
echo json_encode([
'success' => true,
'banner_url' => $result['url'],
'download_url' => $result['url'] . '?download=1',
]);
} else {
http_response_code(422);
echo json_encode([
'success' => false,
'errors' => $result['errors'],
]);
}
Part 4 — The Frontend Form (Complete, with Live Preview)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>I'm Attending — Generate Your Banner</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, 'Segoe UI', sans-serif;
background: #0f172a;
color: #f1f5f9;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.banner-generator {
max-width: 480px;
width: 100%;
background: #1e293b;
border-radius: 16px;
padding: 32px;
box-shadow: 0 8px 40px rgba(0,0,0,0.4);
}
h1 { font-size: 1.4rem; margin-bottom: 6px; }
.subtitle { color: #94a3b8; font-size: 0.9rem; margin-bottom: 24px; }
.field { margin-bottom: 18px; }
label { display: block; font-size: 0.85rem; font-weight: 600; margin-bottom: 6px; color: #cbd5e1; }
input[type="text"] {
width: 100%;
padding: 11px 14px;
background: #0f172a;
border: 1.5px solid #334155;
border-radius: 8px;
color: #f1f5f9;
font-size: 0.95rem;
outline: none;
font-family: inherit;
}
input[type="text"]:focus { border-color: #6366f1; }
.upload-zone {
border: 2px dashed #334155;
border-radius: 10px;
padding: 24px;
text-align: center;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
position: relative;
}
.upload-zone:hover, .upload-zone.dragover { border-color: #6366f1; background: rgba(99,102,241,0.05); }
.upload-zone input[type="file"] { position: absolute; inset: 0; opacity: 0; cursor: pointer; }
.upload-icon { font-size: 2rem; margin-bottom: 8px; }
.upload-text { font-size: 0.85rem; color: #94a3b8; }
.preview-img {
width: 90px; height: 90px;
border-radius: 50%;
object-fit: cover;
margin: 0 auto 10px;
display: none;
border: 3px solid #6366f1;
}
.char-count { font-size: 0.72rem; color: #64748b; text-align: right; margin-top: 4px; }
.submit-btn {
width: 100%;
padding: 13px;
background: linear-gradient(135deg, #6366f1, #4f46e5);
color: #fff;
border: none;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 700;
cursor: pointer;
margin-top: 8px;
transition: opacity 0.2s;
}
.submit-btn:hover:not(:disabled) { opacity: 0.92; }
.submit-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.result-box {
display: none;
margin-top: 24px;
text-align: center;
}
.result-box img {
width: 100%;
border-radius: 10px;
margin-bottom: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
}
.download-btn, .share-btn {
display: inline-block;
padding: 10px 20px;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
text-decoration: none;
margin: 4px;
}
.download-btn { background: #22c55e; color: #fff; }
.share-btn { background: #1da1f2; color: #fff; }
.error-msg {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.3);
color: #fca5a5;
padding: 10px 14px;
border-radius: 8px;
font-size: 0.82rem;
margin-bottom: 16px;
display: none;
}
.loading-spinner {
display: none;
text-align: center;
padding: 20px;
}
.spinner {
width: 32px; height: 32px;
border: 3px solid #334155;
border-top-color: #6366f1;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<div class="banner-generator">
<h1>🎉 I'm Attending!</h1>
<p class="subtitle">Generate your personalized banner and share it with your network.</p>
<div class="error-msg" id="error-box"></div>
<form id="banner-form" enctype="multipart/form-data">
<div class="field">
<label for="stu_name">Your Full Name</label>
<input type="text" id="stu_name" name="stu_name" maxlength="60" required placeholder="John Doe">
<div class="char-count"><span id="name-count">0</span>/60</div>
</div>
<div class="field">
<label>Profile Photo</label>
<div class="upload-zone" id="upload-zone">
<img class="preview-img" id="photo-preview" alt="Preview">
<div id="upload-prompt">
<div class="upload-icon">📸</div>
<div class="upload-text">Tap to upload or drag your photo here<br>
<small>JPEG, PNG, or WebP — max 8MB</small></div>
</div>
<input type="file" id="user_image" name="user_image" accept="image/jpeg,image/png,image/webp" required>
</div>
</div>
<input type="hidden" name="template" value="webinar2025">
<input type="hidden" name="leadcode" id="leadcode" value="">
<button type="submit" class="submit-btn" id="submit-btn">
✨ Generate My Banner
</button>
</form>
<div class="loading-spinner" id="loading">
<div class="spinner"></div>
<p style="font-size:0.85rem;color:#94a3b8;">Creating your banner…</p>
</div>
<div class="result-box" id="result-box">
<img id="result-image" alt="Your personalized banner">
<div>
<a href="#" class="download-btn" id="download-link" download>⬇ Download</a>
<a href="#" class="share-btn" id="twitter-share" target="_blank">𝕏 Share</a>
</div>
</div>
</div>
<script>
(function() {
'use strict';
const form = document.getElementById('banner-form');
const nameInput = document.getElementById('stu_name');
const nameCount = document.getElementById('name-count');
const fileInput = document.getElementById('user_image');
const photoPreview = document.getElementById('photo-preview');
const uploadPrompt = document.getElementById('upload-prompt');
const submitBtn = document.getElementById('submit-btn');
const loading = document.getElementById('loading');
const resultBox = document.getElementById('result-box');
const resultImage = document.getElementById('result-image');
const downloadLink = document.getElementById('download-link');
const twitterShare = document.getElementById('twitter-share');
const errorBox = document.getElementById('error-box');
const leadcodeField = document.getElementById('leadcode');
// Generate a unique lead code client-side
leadcodeField.value = 'attendee_' + Math.random().toString(36).slice(2, 12);
// Character counter
nameInput.addEventListener('input', function() {
nameCount.textContent = this.value.length;
});
// Photo preview
fileInput.addEventListener('change', function() {
const file = this.files[0];
if (!file) return;
// Client-side validation (server validates again — never trust client only)
const maxSize = 8 * 1024 * 1024;
if (file.size > maxSize) {
showError('File too large. Maximum 8MB allowed.');
this.value = '';
return;
}
if (!['image/jpeg', 'image/png', 'image/webp'].includes(file.type)) {
showError('Please upload a JPEG, PNG, or WebP image.');
this.value = '';
return;
}
const reader = new FileReader();
reader.onload = function(e) {
photoPreview.src = e.target.result;
photoPreview.style.display = 'block';
uploadPrompt.style.display = 'none';
};
reader.readAsDataURL(file);
hideError();
});
// Drag and drop
const uploadZone = document.getElementById('upload-zone');
['dragenter', 'dragover'].forEach(evt => {
uploadZone.addEventListener(evt, e => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
});
['dragleave', 'drop'].forEach(evt => {
uploadZone.addEventListener(evt, e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
});
});
uploadZone.addEventListener('drop', e => {
const file = e.dataTransfer.files[0];
if (file) {
fileInput.files = e.dataTransfer.files;
fileInput.dispatchEvent(new Event('change'));
}
});
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
hideError();
if (!fileInput.files[0]) {
showError('Please upload a profile photo.');
return;
}
submitBtn.disabled = true;
form.style.display = 'none';
loading.style.display = 'block';
const formData = new FormData(form);
try {
const response = await fetch('generate_banner.php', {
method: 'POST',
body: formData,
});
const data = await response.json();
loading.style.display = 'none';
if (data.success) {
resultImage.src = data.banner_url + '?t=' + Date.now();
downloadLink.href = data.download_url;
twitterShare.href = 'https://twitter.com/intent/tweet?text=' +
encodeURIComponent("I'm attending! Join me 🎉") +
'&url=' + encodeURIComponent(window.location.href);
resultBox.style.display = 'block';
} else {
form.style.display = 'block';
showError(data.errors?.[0] || 'Something went wrong. Please try again.');
}
} catch (err) {
loading.style.display = 'none';
form.style.display = 'block';
showError('Network error. Please check your connection and try again.');
console.error(err);
}
submitBtn.disabled = false;
});
function showError(msg) {
errorBox.textContent = '⚠️ ' + msg;
errorBox.style.display = 'block';
}
function hideError() {
errorBox.style.display = 'none';
}
})();
</script>
</body>
</html>
Part 5 — Comparison: GD Library vs Imagick for This Task
┌─────────────────────────────────────────────────────────────────────────────┐ │ Feature │ GD Library │ Imagick (ImageMagick) │ ├─────────────────────────────────────────────────────────────────────────────┤ │ Installation │ Usually pre-installed│ Requires separate install │ │ Circular crop quality │ Manual (shown above)│ Native, easier │ │ WebP support │ PHP 7.1+ │ Yes, broader format support│ │ Font rendering quality │ Good │ Excellent (better AA) │ │ Memory usage │ Lower │ Higher │ │ Processing speed │ Faster for simple ops│ Slower but more capable │ │ Filters/effects │ Basic │ Extensive (blur, etc.) │ │ Animated GIF support │ Limited │ Full support │ │ PDF generation │ No │ Yes │ │ Shared hosting availability │ Almost universal │ Less common │ └─────────────────────────────────────────────────────────────────────────────┘
Equivalent Circular Crop Using Imagick (Much Simpler)
<?php
/**
* If Imagick is available, circular cropping is dramatically simpler.
* Use this as an alternative to the GD pixel-by-pixel approach in Part 2.
*/
function createCircularAvatarImagick(string $photoPath, int $size): \Imagick {
$image = new \Imagick($photoPath);
$image->setImageFormat('png');
// Auto-orient based on EXIF (built-in, no manual rotation logic needed)
$image->autoOrientImage();
// Crop to square first
$width = $image->getImageWidth();
$height = $image->getImageHeight();
$cropSize = min($width, $height);
$x = (int) (($width - $cropSize) / 2);
$y = (int) (($height - $cropSize) / 2);
$image->cropImage($cropSize, $cropSize, $x, $y);
// Resize to target size with high-quality filter
$image->resizeImage($size, $size, \Imagick::FILTER_LANCZOS, 1);
// Create circular mask
$mask = new \Imagick();
$mask->newImage($size, $size, new \ImagickPixel('transparent'));
$draw = new \ImagickDraw();
$draw->setFillColor(new \ImagickPixel('white'));
$draw->circle($size / 2, $size / 2, $size / 2, 0);
$mask->drawImage($draw);
// Apply mask
$image->compositeImage($mask, \Imagick::COMPOSITE_DSTIN, 0, 0);
return $image;
}
// Usage in a full Imagick-based generator:
function generateBannerImagick(
string $templatePath,
string $photoPath,
string $name,
string $fontPath,
string $outputPath
): bool {
$banner = new \Imagick($templatePath);
$banner->setImageFormat('png');
// Create circular avatar
$avatar = createCircularAvatarImagick($photoPath, 210);
// Composite onto banner
$banner->compositeImage($avatar, \Imagick::COMPOSITE_OVER, 200, 255);
// Add text with proper centering
$draw = new \ImagickDraw();
$draw->setFont($fontPath);
$draw->setFontSize(18);
$draw->setFillColor(new \ImagickPixel('white'));
$draw->setTextAlignment(\Imagick::ALIGN_CENTER);
$metrics = $banner->queryFontMetrics($draw, $name);
$centerX = $banner->getImageWidth() / 2;
$banner->annotateImage($draw, $centerX, 510, 0, $name);
$result = $banner->writeImage($outputPath);
$banner->destroy();
$avatar->destroy();
return $result;
}
Part 6 — Performance and Scaling for High-Traffic Events
For events with thousands of attendees generating banners simultaneously, the synchronous approach in Part 2-3 will create bottlenecks.
Strategy 1: Queue-Based Generation (Recommended for Large Events)
<?php
/**
* Queue banner generation instead of processing synchronously.
* Returns immediately to the user with a "processing" status,
* then generates the banner in the background via cron or a worker.
*/
// Step 1: Quick validation + queue insertion (fast response)
function queueBannerGeneration(array $uploadedFile, string $name, string $leadCode): array {
// Move uploaded file to a persistent queue location
// (tmp_name gets deleted after the request — must move it now)
$queueDir = __DIR__ . '/queue/uploads';
if (!is_dir($queueDir)) mkdir($queueDir, 0755, true);
$queuedFilename = $leadCode . '_' . uniqid() . '.tmp';
$queuedPath = $queueDir . '/' . $queuedFilename;
if (!move_uploaded_file($uploadedFile['tmp_name'], $queuedPath)) {
return ['success' => false, 'errors' => ['Failed to queue upload.']];
}
// Insert into database queue
global $pdo;
$stmt = $pdo->prepare(
"INSERT INTO banner_queue (lead_code, name, photo_path, status, created_at)
VALUES (:code, :name, :path, 'pending', NOW())"
);
$stmt->execute([
':code' => $leadCode,
':name' => $name,
':path' => $queuedPath,
]);
return [
'success' => true,
'queue_id' => $pdo->lastInsertId(),
'message' => 'Your banner is being generated. Check back in a few seconds.',
];
}
// Step 2: Background worker (run via cron every minute, or a long-running daemon)
function processQueue(): void {
global $pdo;
$stmt = $pdo->query(
"SELECT * FROM banner_queue WHERE status = 'pending' ORDER BY created_at ASC LIMIT 20"
);
$generator = new EventBannerGenerator(
__DIR__ . '/templates/attending-webinar-2025.png',
__DIR__ . '/generated',
__DIR__ . '/fonts/Inter-Bold.ttf'
);
while ($job = $stmt->fetch(PDO::FETCH_ASSOC)) {
$result = $generator->generate(
uploadedFile: [
'tmp_name' => $job['photo_path'],
'error' => UPLOAD_ERR_OK,
'size' => filesize($job['photo_path']),
],
attendeeName: $job['name'],
uniqueId: $job['lead_code']
);
$update = $pdo->prepare(
"UPDATE banner_queue SET status = :status, result_url = :url, processed_at = NOW()
WHERE id = :id"
);
$update->execute([
':status' => $result['success'] ? 'completed' : 'failed',
':url' => $result['url'] ?? '',
':id' => $job['id'],
]);
// Clean up the queued upload file
@unlink($job['photo_path']);
}
}
Strategy 2: Automated Cleanup of Generated Files
<?php
/**
* Clean up old generated banners to prevent disk space exhaustion.
* Run via cron: 0 3 * * * php /path/to/cleanup.php
*/
function cleanupOldBanners(string $directory, int $maxAgeDays = 30): int {
$deleted = 0;
$cutoff = time() - ($maxAgeDays * 86400);
foreach (glob($directory . '/*.png') as $file) {
if (filemtime($file) < $cutoff) {
unlink($file);
$deleted++;
}
}
return $deleted;
}
$count = cleanupOldBanners(__DIR__ . '/generated', 30);
echo "Cleaned up {$count} old banner files.\n";
Part 7 — Common Errors and Fixes
Error 1: “Call to undefined function imagecreatefrompng()”
# GD extension is not installed sudo apt install php8.2-gd # Ubuntu/Debian sudo systemctl restart php8.2-fpm # Verify: php -m | grep gd
Error 2: “imagettftext(): Could not find/open font”
// Cause: Wrong path or insufficient permissions
// Fix: Use absolute path, verify file exists and is readable
$fontPath = __DIR__ . '/fonts/Inter-Bold.ttf'; // Use __DIR__, not relative paths
if (!file_exists($fontPath)) {
die("Font not found at: {$fontPath}");
}
if (!is_readable($fontPath)) {
die("Font exists but is not readable. Check permissions: " . substr(sprintf('%o', fileperms($fontPath)), -4));
}
Error 3: Generated images look pixelated/blurry
// Cause: Using imagescale() instead of imagecopyresampled() // Already fixed in Part 2 — always use imagecopyresampled() for quality
Error 4: “Allowed memory size exhausted” on large uploads
; In php.ini, increase memory for image processing: memory_limit = 256M upload_max_filesize = 10M post_max_size = 12M
// Or set per-script (less ideal, but works for shared hosting):
ini_set('memory_limit', '256M');
Error 5: Circular crop has jagged/pixelated edges
// Cause: Not using the anti-aliasing alpha blending shown in Part 2 // The pixel-by-pixel circular crop with alpha blending solves this // Alternative: Use Imagick (Part 5) which has built-in anti-aliasing
Error 6: Phone photos appear sideways
// Cause: Missing EXIF orientation correction // Already fixed in Part 2's correctOrientation() method // Requires the exif extension: php -m | grep exif // If missing: sudo apt install php8.2-exif
From a Working Demo to a Production System
The original tutorial’s code generates a banner — but it would fail or get exploited the moment it met real-world usage. The gap between “code that works in a demo” and “code safe for public-facing production” is exactly what this guide closes:
Security fixes applied:
- File uploads validated by actual content (finfo), not just claimed MIME type
- Path traversal prevented through strict ID sanitization and template whitelisting
- File size and dimension limits prevent memory exhaustion attacks
- Rate limiting prevents abuse of the generation endpoint
Quality fixes applied:
- imagecopyresampled() instead of imagescale() for sharp photo quality
- EXIF orientation correction fixes sideways phone photos
- Circular avatar cropping with proper anti-aliasing (not jagged edges)
- Multi-line text wrapping for long names
- Square-crop-then-resize prevents distorted ovals from non-square uploads
Production readiness added:
- Proper error handling at every GD function call (the original had none)
- A complete, validated frontend with live preview and drag-and-drop
- Queue-based processing pattern for high-traffic events
- Automated cleanup to prevent disk space exhaustion
- Imagick alternative for teams wanting simpler circular crops
This system is what actually ships in production for real conferences, webinars, and corporate events — not just a code snippet that works on the developer’s machine with a clean test image.
With just a few lines of PHP using the GD library, you can automate the creation of professional, personalized event banners.
It’s a great way to encourage attendees to share their participation and boost event visibility on social media.
I am now not positive where you’re getting your information, however good topic.
I needs to spend some time learning much more or working out more.
Thank you for excellent info I used to be looking for this information for my mission.