Code1180
How to Host a Static Site on AWS S3 with Laravel
How to Host a Static Site on AWS S3 with Laravel
Learn how to build a complete static site generator in Laravel that converts Markdown files to HTML and deploys them to AWS S3 with CloudFront CDN. This tutorial covers everything from code architecture to AWS configuration.
Table of Contents
- Overview
- Prerequisites
- Architecture Overview
- Step 1: Install Dependencies
- Step 2: Create the Data Transfer Object
- Step 3: Build the Markdown Parser
- Step 4: Create Supporting Services
- Step 5: Build the Site Generator
- Step 6: Create the S3 Uploader
- Step 7: Build Artisan Commands
- Step 8: Configure AWS
- Step 9: Deploy Your Site
- Testing
- Troubleshooting
- Conclusion
Overview
This tutorial demonstrates how to build a production-ready static site generator that:
- ✅ Converts Markdown files with YAML frontmatter to HTML
- ✅ Auto-generates navigation from all pages
- ✅ Includes complete SEO optimization (Open Graph, Twitter Cards, Schema.org)
- ✅ Implements dark mode with localStorage
- ✅ Uploads to AWS S3 with proper MIME types
- ✅ Serves via CloudFront CDN with SSL
- ✅ Fully tested with Pest
Tech Stack:
- Laravel 12
- league/commonmark 2.8 (Markdown parsing)
- symfony/yaml 7.4 (YAML parsing)
- AWS SDK for PHP
- Tailwind CSS v4
- Pest testing framework
Prerequisites
- PHP 8.2+
- Laravel 12 application
- Composer
- AWS account
- Domain registered with Route 53
- Basic knowledge of Laravel and AWS
Architecture Overview
Our static site generator consists of:
app/
├── DataTransferObjects/
│ └── PageMetadata.php # Holds page metadata
├── Services/StaticSite/
│ ├── MarkdownParser.php # Parses YAML + Markdown
│ ├── NavigationBuilder.php # Builds navigation
│ ├── HtmlTemplate.php # Generates HTML
│ ├── AssetCopier.php # Copies static assets
│ ├── SiteGenerator.php # Orchestrates generation
│ └── S3Uploader.php # Uploads to S3
└── Console/Commands/
├── GenerateStaticSite.php # Artisan command
└── UploadStaticSiteToS3.php # Artisan command
Step 1: Install Dependencies
Install the required packages:
composer require aws/aws-sdk-php league/flysystem-aws-s3-v3
These packages provide:
league/commonmark- Already in Laravel (Markdown parsing)symfony/yaml- Already in Laravel (YAML parsing)aws/aws-sdk-php- AWS SDK for S3 operationsleague/flysystem-aws-s3-v3- Laravel Flysystem adapter for S3
Step 2: Create the Data Transfer Object
Create a DTO to hold page metadata from YAML frontmatter.
File: app/DataTransferObjects/PageMetadata.php
<?php
namespace App\DataTransferObjects;
use InvalidArgumentException;
readonly class PageMetadata
{
public function __construct(
public string $title,
public string $description,
public ?string $date,
public array $keywords,
public string $slug,
public int $order,
) {
if (empty($this->title)) {
throw new InvalidArgumentException('Title cannot be empty');
}
if (empty($this->slug)) {
throw new InvalidArgumentException('Slug cannot be empty');
}
}
/**
* Create instance from frontmatter array.
*/
public static function fromFrontmatter(array $frontmatter, string $defaultSlug): self
{
return new self(
title: $frontmatter['title'] ?? 'Untitled',
description: $frontmatter['description'] ?? '',
date: $frontmatter['date'] ?? null,
keywords: $frontmatter['keywords'] ?? [],
slug: $frontmatter['slug'] ?? $defaultSlug,
order: $frontmatter['order'] ?? 999,
);
}
/**
* Get formatted date for display.
*/
public function getFormattedDate(): string
{
if (!$this->date) {
return '';
}
$timestamp = strtotime($this->date);
if ($timestamp === false) {
return $this->date;
}
return date('F j, Y', $timestamp);
}
/**
* Get keywords as comma-separated string.
*/
public function getKeywordsString(): string
{
return implode(', ', $this->keywords);
}
/**
* Get full URL for this page.
*/
public function getUrl(): string
{
return 'https://www.code1180.com/' . ($this->slug === 'index' ? '' : $this->slug . '.html');
}
}
Key Features:
- Immutable with
readonlykeyword - Factory method
fromFrontmatter()for easy instantiation - Helper methods for formatting dates and keywords
- Validation in constructor
Step 3: Build the Markdown Parser
Parse YAML frontmatter and convert Markdown to HTML.
File: app/Services/StaticSite/MarkdownParser.php
<?php
namespace App\Services\StaticSite;
use League\CommonMark\CommonMarkConverter;
use League\CommonMark\Environment\Environment;
use League\CommonMark\Extension\CommonMark\CommonMarkCoreExtension;
use Symfony\Component\Yaml\Yaml;
class MarkdownParser
{
protected CommonMarkConverter $converter;
public function __construct()
{
$environment = new Environment([
'html_input' => 'strip',
'allow_unsafe_links' => false,
]);
$environment->addExtension(new CommonMarkCoreExtension);
$this->converter = new CommonMarkConverter([], $environment);
}
/**
* Parse markdown file with YAML frontmatter.
*/
public function parse(string $filePath): array
{
if (!file_exists($filePath)) {
throw new \InvalidArgumentException("File not found: {$filePath}");
}
$content = file_get_contents($filePath);
return $this->parseContent($content, basename($filePath, '.md'));
}
/**
* Parse markdown content with YAML frontmatter.
*/
public function parseContent(string $content, string $defaultSlug): array
{
$parts = $this->extractFrontmatter($content);
return [
'frontmatter' => $parts['frontmatter'],
'html' => $this->convertMarkdown($parts['markdown']),
'slug' => $parts['frontmatter']['slug'] ?? $defaultSlug,
];
}
/**
* Extract YAML frontmatter from content.
*/
protected function extractFrontmatter(string $content): array
{
// Match content between --- delimiters
if (preg_match('/^---\s*\n(.*?)\n---\s*\n(.*)$/s', $content, $matches)) {
try {
$frontmatter = Yaml::parse($matches[1]) ?? [];
} catch (\Exception $e) {
$frontmatter = [];
}
return [
'frontmatter' => $frontmatter,
'markdown' => $matches[2],
];
}
// No frontmatter found
return [
'frontmatter' => [],
'markdown' => $content,
];
}
/**
* Convert markdown to HTML.
*/
protected function convertMarkdown(string $markdown): string
{
return $this->converter->convert($markdown)->getContent();
}
}
Features:
- Parses YAML frontmatter between
---delimiters - Converts Markdown to HTML using CommonMark
- Strips unsafe HTML and links for security
- Gracefully handles missing frontmatter
Step 4: Create Supporting Services
Navigation Builder
File: app/Services/StaticSite/NavigationBuilder.php
<?php
namespace App\Services\StaticSite;
use App\DataTransferObjects\PageMetadata;
class NavigationBuilder
{
/**
* Build navigation array from pages.
*/
public function build(array $pages): array
{
$navigation = [];
foreach ($pages as $page) {
$metadata = $page['metadata'];
$navigation[] = [
'title' => $metadata->title,
'url' => '/' . ($metadata->slug === 'index' ? '' : $metadata->slug . '.html'),
'order' => $metadata->order,
];
}
// Sort by order
usort($navigation, fn($a, $b) => $a['order'] <=> $b['order']);
return $navigation;
}
}
Asset Copier
File: app/Services/StaticSite/AssetCopier.php
<?php
namespace App\Services\StaticSite;
use Symfony\Component\Filesystem\Filesystem;
class AssetCopier
{
public function __construct(protected Filesystem $filesystem = new Filesystem) {}
/**
* Copy static assets from source to destination.
*/
public function copy(): int
{
$source = resource_path('pages/assets');
$destination = public_path('static-site/assets');
if (!is_dir($source)) {
return 0;
}
$this->filesystem->mirror($source, $destination, null, ['override' => true]);
return $this->countFiles($destination);
}
protected function countFiles(string $directory): int
{
if (!is_dir($directory)) {
return 0;
}
$count = 0;
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($directory, \RecursiveDirectoryIterator::SKIP_DOTS)
);
foreach ($iterator as $file) {
if ($file->isFile()) {
$count++;
}
}
return $count;
}
}
HTML Template Generator
File: app/Services/StaticSite/HtmlTemplate.php
This is the largest file - it generates complete HTML with SEO, dark mode, and navigation.
<?php
namespace App\Services\StaticSite;
use App\DataTransferObjects\PageMetadata;
class HtmlTemplate
{
/**
* Generate complete HTML page.
*/
public function generate(PageMetadata $metadata, string $content, array $navigation): string
{
$head = $this->generateHead($metadata);
$nav = $this->generateNavigation($navigation, $metadata->slug);
$structuredData = $this->generateStructuredData($metadata);
$darkModeScript = $this->generateDarkModeScript();
return <<<HTML
<!DOCTYPE html>
<html lang="en-US">
<head>
{$head}
</head>
<body class="bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 min-h-screen flex flex-col">
<header class="border-b border-gray-200 dark:border-gray-800">
<div class="container mx-auto px-4 py-6 flex justify-between items-center max-w-6xl">
<a href="/" class="text-2xl font-bold text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 transition-colors">
Code1180
</a>
<nav class="flex items-center gap-6">
{$nav}
<button id="theme-toggle" class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors" aria-label="Toggle dark mode">
<svg class="sun-icon w-6 h-6 text-gray-600 dark:text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z"></path>
</svg>
<svg class="moon-icon w-6 h-6 text-gray-600 dark:text-gray-400 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z"></path>
</svg>
</button>
</nav>
</div>
</header>
<main class="container mx-auto px-4 py-12 max-w-6xl flex-grow">
<article class="prose prose-lg dark:prose-invert max-w-none">
<header class="mb-8">
<h1 class="text-4xl font-bold mb-4 text-gray-900 dark:text-white">{$metadata->title}</h1>
{$this->generateDateDisplay($metadata)}
</header>
<div class="content">
{$content}
</div>
</article>
</main>
<footer class="border-t border-gray-200 dark:border-gray-800 mt-16 py-8">
<div class="container mx-auto px-4 text-center text-gray-600 dark:text-gray-400 max-w-6xl">
<p>
© 2024 Praveen Dias |
<a href="https://www.facebook.com/code1180" target="_blank" rel="noopener noreferrer" class="text-blue-600 dark:text-blue-400 hover:underline">Facebook</a> |
<a href="https://www.youtube.com/@code1180com" target="_blank" rel="noopener noreferrer" class="text-red-600 dark:text-red-400 hover:underline">YouTube</a>
</p>
</div>
</footer>
{$structuredData}
{$darkModeScript}
</body>
</html>
HTML;
}
protected function generateHead(PageMetadata $metadata): string
{
$title = htmlspecialchars($metadata->title, ENT_QUOTES, 'UTF-8');
$description = htmlspecialchars($metadata->description, ENT_QUOTES, 'UTF-8');
$keywords = htmlspecialchars($metadata->getKeywordsString(), ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($metadata->getUrl(), ENT_QUOTES, 'UTF-8');
return <<<HTML
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<!-- SEO Meta Tags -->
<title>{$title} | Code1180</title>
<meta name="description" content="{$description}">
<meta name="keywords" content="{$keywords}">
<meta name="author" content="Praveen Dias">
<!-- Open Graph -->
<meta property="og:title" content="{$title}">
<meta property="og:description" content="{$description}">
<meta property="og:url" content="{$url}">
<meta property="og:site_name" content="Code1180">
<meta property="og:type" content="article">
<!-- Twitter Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="{$title}">
<meta name="twitter:description" content="{$description}">
<!-- Theme -->
<meta name="theme-color" content="#737373">
<link rel="canonical" href="{$url}">
<!-- Tailwind CSS CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
darkMode: 'class',
theme: {
extend: {
typography: {
DEFAULT: {
css: {
maxWidth: 'none',
color: '#374151',
a: {
color: '#2563eb',
'&:hover': { color: '#1d4ed8' }
},
code: {
color: '#1f2937',
backgroundColor: '#f3f4f6',
padding: '0.25rem 0.5rem',
borderRadius: '0.25rem'
}
}
},
invert: {
css: {
color: '#e5e7eb',
a: {
color: '#60a5fa',
'&:hover': { color: '#93c5fd' }
},
code: {
color: '#f9fafb',
backgroundColor: '#374151'
}
}
}
}
}
}
}
</script>
HTML;
}
protected function generateNavigation(array $navigation, string $currentSlug): string
{
$links = [];
foreach ($navigation as $item) {
$title = htmlspecialchars($item['title'], ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($item['url'], ENT_QUOTES, 'UTF-8');
$activeClass = '';
if ($item['url'] === '/' && $currentSlug === 'index') {
$activeClass = ' font-semibold';
} elseif ($item['url'] !== '/' && str_contains($item['url'], $currentSlug)) {
$activeClass = ' font-semibold';
}
$links[] = "<a href=\"{$url}\" class=\"text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white transition-colors{$activeClass}\">{$title}</a>";
}
return implode("\n ", $links);
}
protected function generateDateDisplay(PageMetadata $metadata): string
{
if (!$metadata->date) {
return '';
}
$datetime = htmlspecialchars($metadata->date, ENT_QUOTES, 'UTF-8');
$formatted = htmlspecialchars($metadata->getFormattedDate(), ENT_QUOTES, 'UTF-8');
return "<time datetime=\"{$datetime}\" class=\"text-gray-600 dark:text-gray-400\">{$formatted}</time>";
}
protected function generateStructuredData(PageMetadata $metadata): string
{
$title = htmlspecialchars($metadata->title, ENT_QUOTES, 'UTF-8');
$description = htmlspecialchars($metadata->description, ENT_QUOTES, 'UTF-8');
$url = htmlspecialchars($metadata->getUrl(), ENT_QUOTES, 'UTF-8');
$date = $metadata->date ?? date('Y-m-d');
return <<<HTML
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "BlogPosting",
"headline": "{$title}",
"description": "{$description}",
"datePublished": "{$date}",
"dateModified": "{$date}",
"author": {
"@type": "Person",
"name": "Praveen Dias"
},
"mainEntityOfPage": {
"@type": "WebPage",
"@id": "{$url}"
},
"url": "{$url}"
}
</script>
HTML;
}
protected function generateDarkModeScript(): string
{
return <<<'HTML'
<script>
const theme = localStorage.getItem('theme') || 'light';
if (theme === 'dark') {
document.documentElement.classList.add('dark');
document.querySelector('.moon-icon').classList.remove('hidden');
document.querySelector('.sun-icon').classList.add('hidden');
}
document.getElementById('theme-toggle').addEventListener('click', function() {
document.documentElement.classList.toggle('dark');
const isDark = document.documentElement.classList.contains('dark');
localStorage.setItem('theme', isDark ? 'dark' : 'light');
document.querySelector('.sun-icon').classList.toggle('hidden');
document.querySelector('.moon-icon').classList.toggle('hidden');
});
</script>
HTML;
}
}
Step 5: Build the Site Generator
Orchestrates the entire generation process.
File: app/Services/StaticSite/SiteGenerator.php
<?php
namespace App\Services\StaticSite;
use App\DataTransferObjects\PageMetadata;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Facades\File;
use Symfony\Component\Finder\Finder;
class SiteGenerator
{
public function __construct(
protected MarkdownParser $parser,
protected HtmlTemplate $template,
protected NavigationBuilder $navigationBuilder,
protected AssetCopier $assetCopier,
) {}
/**
* Generate static site from markdown files.
*/
public function generate(bool $verbose = false, ?OutputStyle $output = null): array
{
if ($verbose && $output) {
$output->info('Starting site generation...');
}
// Clean up existing static site directory
$staticSiteDir = public_path('static-site');
if (is_dir($staticSiteDir)) {
File::deleteDirectory($staticSiteDir);
}
File::makeDirectory($staticSiteDir, 0755, true);
if ($verbose && $output) {
$output->info('Discovering markdown files...');
}
// Discover and parse all pages
$pages = $this->discoverPages($verbose, $output);
if (empty($pages)) {
if ($verbose && $output) {
$output->warn('No markdown files found in resources/pages/');
}
return ['pages' => 0, 'assets' => 0];
}
// Build navigation
$navigation = $this->navigationBuilder->build($pages);
if ($verbose && $output) {
$output->info('Generating HTML pages...');
}
// Generate HTML for each page
foreach ($pages as $page) {
$html = $this->template->generate(
$page['metadata'],
$page['html'],
$navigation
);
$filename = $page['metadata']->slug === 'index'
? 'index.html'
: $page['metadata']->slug . '.html';
$filepath = $staticSiteDir . '/' . $filename;
file_put_contents($filepath, $html);
if ($verbose && $output) {
$output->writeln(" Generated: {$filename}");
}
}
if ($verbose && $output) {
$output->info('Copying static assets...');
}
// Copy static assets
$assetsCount = $this->assetCopier->copy();
return [
'pages' => count($pages),
'assets' => $assetsCount,
];
}
/**
* Discover all markdown files and parse them.
*/
protected function discoverPages(bool $verbose = false, ?OutputStyle $output = null): array
{
$pagesDir = resource_path('pages');
if (!is_dir($pagesDir)) {
return [];
}
$finder = new Finder;
$finder->files()->in($pagesDir)->name('*.md')->sortByName();
$pages = [];
foreach ($finder as $file) {
$parsed = $this->parser->parse($file->getRealPath());
$metadata = PageMetadata::fromFrontmatter(
$parsed['frontmatter'],
$parsed['slug']
);
$pages[] = [
'metadata' => $metadata,
'html' => $parsed['html'],
];
if ($verbose && $output) {
$output->writeln(" Parsed: {$file->getFilename()}");
}
}
return $this->sortPages($pages);
}
/**
* Sort pages by order, date, then title.
*/
protected function sortPages(array $pages): array
{
usort($pages, function($a, $b) {
$metaA = $a['metadata'];
$metaB = $b['metadata'];
if ($metaA->order !== $metaB->order) {
return $metaA->order <=> $metaB->order;
}
if ($metaA->date !== null && $metaB->date !== null) {
$dateComparison = strtotime($metaB->date) <=> strtotime($metaA->date);
if ($dateComparison !== 0) {
return $dateComparison;
}
}
return $metaA->title <=> $metaB->title;
});
return $pages;
}
}
Step 6: Create the S3 Uploader
Uploads files to S3 with proper MIME types.
File: app/Services/StaticSite/S3Uploader.php
<?php
namespace App\Services\StaticSite;
use Illuminate\Console\OutputStyle;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Symfony\Component\Finder\Finder;
class S3Uploader
{
/**
* Upload static site files to S3.
*/
public function upload(bool $verbose = false, ?OutputStyle $output = null): array
{
$sourceDir = public_path('static-site');
if (!is_dir($sourceDir)) {
if ($verbose && $output) {
$output->error('Static site directory not found. Run site:generate first.');
}
return ['uploaded' => 0, 'size' => 0, 'errors' => []];
}
$finder = new Finder;
$finder->files()->in($sourceDir);
$stats = [
'uploaded' => 0,
'size' => 0,
'errors' => [],
];
foreach ($finder as $file) {
$relativePath = $file->getRelativePathname();
$realPath = $file->getRealPath();
try {
$contents = file_get_contents($realPath);
$mimeType = $this->determineMimeType($file->getExtension());
Storage::disk('s3')->put(
$relativePath,
$contents,
[
'visibility' => 'public',
'ContentType' => $mimeType,
'CacheControl' => 'max-age=31536000',
]
);
$stats['uploaded']++;
$stats['size'] += $file->getSize();
if ($verbose && $output) {
$output->writeln(" Uploaded: {$relativePath}");
}
} catch (\Exception $e) {
$stats['errors'][] = [
'file' => $relativePath,
'error' => $e->getMessage(),
];
Log::error("Failed to upload {$relativePath}: " . $e->getMessage());
if ($verbose && $output) {
$output->error(" Failed: {$relativePath}");
}
}
}
return $stats;
}
/**
* Determine MIME type from file extension.
*/
protected function determineMimeType(string $extension): string
{
$mimeTypes = [
'html' => 'text/html',
'css' => 'text/css',
'js' => 'application/javascript',
'json' => 'application/json',
'png' => 'image/png',
'jpg' => 'image/jpeg',
'jpeg' => 'image/jpeg',
'gif' => 'image/gif',
'svg' => 'image/svg+xml',
'webp' => 'image/webp',
'woff' => 'font/woff',
'woff2' => 'font/woff2',
'ttf' => 'font/ttf',
];
return $mimeTypes[strtolower($extension)] ?? 'application/octet-stream';
}
}
Step 7: Build Artisan Commands
Create commands to generate and upload the site.
Generate Command
File: app/Console/Commands/GenerateStaticSite.php
<?php
namespace App\Console\Commands;
use App\Services\StaticSite\SiteGenerator;
use Illuminate\Console\Command;
class GenerateStaticSite extends Command
{
protected $signature = 'site:generate';
protected $description = 'Generate static HTML site from markdown files';
public function __construct(protected SiteGenerator $generator)
{
parent::__construct();
}
public function handle(): int
{
$verbose = $this->output->isVerbose();
$stats = $this->generator->generate($verbose, $verbose ? $this->output : null);
if ($verbose) {
$this->newLine();
$this->info('Site generation complete!');
$this->info("Generated {$stats['pages']} pages");
$this->info("Copied {$stats['assets']} asset files");
}
return Command::SUCCESS;
}
}
Upload Command
File: app/Console/Commands/UploadStaticSiteToS3.php
<?php
namespace App\Console\Commands;
use App\Services\StaticSite\S3Uploader;
use Illuminate\Console\Command;
class UploadStaticSiteToS3 extends Command
{
protected $signature = 'site:upload';
protected $description = 'Upload static site files to S3';
public function __construct(protected S3Uploader $uploader)
{
parent::__construct();
}
public function handle(): int
{
$verbose = $this->output->isVerbose();
if (!config('filesystems.disks.s3.key')) {
$this->error('AWS credentials not configured');
$this->info('Set AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, and AWS_BUCKET in .env');
return Command::FAILURE;
}
if ($verbose) {
$this->info('Starting S3 upload...');
$this->newLine();
}
$stats = $this->uploader->upload($verbose, $verbose ? $this->output : null);
if ($verbose) {
$this->newLine();
}
$this->info('Upload complete!');
$this->info("Files uploaded: {$stats['uploaded']}");
$this->info("Total size: " . $this->formatBytes($stats['size']));
$this->info('S3 URL: https://www.code1180.com/');
if (!empty($stats['errors'])) {
$this->newLine();
$this->warn('Errors: ' . count($stats['errors']));
foreach ($stats['errors'] as $error) {
$this->error("{$error['file']}: {$error['error']}");
}
return Command::FAILURE;
}
return Command::SUCCESS;
}
private function formatBytes(int $bytes): string
{
if ($bytes >= 1073741824) return number_format($bytes / 1073741824, 2) . ' GB';
if ($bytes >= 1048576) return number_format($bytes / 1048576, 2) . ' MB';
if ($bytes >= 1024) return number_format($bytes / 1024, 2) . ' KB';
return $bytes . ' bytes';
}
}
Laravel 12 auto-registers commands from app/Console/Commands/ - no manual registration needed!
Step 8: Configure AWS
8.1: Create S3 Bucket
# Via AWS Console:
# 1. Go to S3 Console
# 2. Create bucket: www.code1180.com
# 3. Region: us-east-1
# 4. Uncheck "Block all public access"
# 5. Create bucket
8.2: Enable Static Website Hosting
- Select bucket → Properties
- Static website hosting → Edit
- Enable
- Index document:
index.html - Save
- Note the endpoint:
http://www.code1180.com.s3-website-us-east-1.amazonaws.com
8.3: Configure Bucket Policy
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::www.code1180.com/*"
}]
}
Apply in: S3 Console → Bucket → Permissions → Bucket Policy
8.4: Create IAM User
# Via AWS Console:
# 1. IAM → Users → Add user
# 2. Username: laravel-static-site-uploader
# 3. Access type: Programmatic
# 4. Attach policy: AmazonS3FullAccess (or create custom policy)
# 5. Copy Access Key ID and Secret
Custom IAM Policy (Recommended):
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:PutObjectAcl",
"s3:GetObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::www.code1180.com",
"arn:aws:s3:::www.code1180.com/*"
]
}]
}
8.5: Update Laravel Environment
Add to .env:
AWS_ACCESS_KEY_ID=AKIA...
AWS_SECRET_ACCESS_KEY=abc123...
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=www.code1180.com
AWS_URL=https://www.code1180.com
8.6: CloudFront Distribution
- CloudFront Console → Create distribution
- Origin domain:
www.code1180.com.s3-website-us-east-1.amazonaws.com- ⚠️ Use the S3 website endpoint, NOT the bucket endpoint
- ⚠️ This is the #1 cause of 504 errors!
- Protocol: HTTP only
- Viewer protocol: Redirect HTTP to HTTPS
- Alternate CNAMEs:
www.code1180.com - SSL: Use ACM certificate
- Default root object:
index.html - Create
8.7: SSL Certificate (ACM)
- ACM Console (us-east-1 region required for CloudFront)
- Request certificate
- Domains:
www.code1180.com,code1180.com - Validation: DNS (create CNAME records in Route 53)
- Wait for validation
- Attach to CloudFront distribution
8.8: Route 53 DNS
- Route 53 → Hosted zones → code1180.com
- Create record:
- Name:
www - Type: A (Alias)
- Alias to: CloudFront distribution
- Create
- Name:
Step 9: Deploy Your Site
Create Markdown Content
File: resources/pages/index.md
---
title: "Home"
description: "Welcome to my blog"
date: 2024-01-04
keywords: ["blog", "coding"]
slug: index
order: 1
---
# Welcome
This is my static site hosted on AWS S3!
Generate and Deploy
# 1. Generate static HTML
php artisan site:generate -v
# Output:
# Starting site generation...
# Discovering markdown files...
# Parsed: index.md
# Generating HTML pages...
# Generated: index.html
# Site generation complete!
# 2. Preview locally (optional)
php -S localhost:8000 -t public/static-site
# 3. Upload to S3
php artisan site:upload -v
# Output:
# Starting S3 upload...
# Uploaded: index.html
# Upload complete!
# Files uploaded: 1
# S3 URL: https://www.code1180.com/
Verify Deployment
# Test S3 website endpoint directly
curl -I http://www.code1180.com.s3-website-us-east-1.amazonaws.com/
# Expected: HTTP/1.1 200 OK
# Test CloudFront (after 10-15 min deployment)
curl -I https://d3dxt8xf00gswx.cloudfront.net/
# Expected: HTTP/2 200
Testing
Create comprehensive tests using Pest.
File: tests/Feature/Console/GenerateStaticSiteTest.php
<?php
use Illuminate\Support\Facades\File;
beforeEach(function() {
File::deleteDirectory(public_path('static-site'));
if (!is_dir(resource_path('pages'))) {
File::makeDirectory(resource_path('pages'), 0755, true);
}
File::put(resource_path('pages/test.md'), <<<'MD'
---
title: "Test"
description: "Test page"
date: 2024-01-01
keywords: ["test"]
slug: test
order: 1
---
# Test Content
MD
);
});
it('generates static HTML files from markdown', function() {
artisan('site:generate')->assertExitCode(0);
expect(public_path('static-site/test.html'))->toBeFile();
});
it('includes SEO meta tags', function() {
artisan('site:generate');
$html = file_get_contents(public_path('static-site/test.html'));
expect($html)
->toContain('og:title')
->toContain('twitter:card')
->toContain('application/ld+json');
});
it('includes dark mode toggle', function() {
artisan('site:generate');
$html = file_get_contents(public_path('static-site/test.html'));
expect($html)
->toContain('theme-toggle')
->toContain('localStorage');
});
Run tests:
php artisan test tests/Feature/Console/
Troubleshooting
504 Gateway Timeout (CloudFront)
Cause: Wrong CloudFront origin
Fix:
- CloudFront Console → Distribution → Origins
- Change from:
www.code1180.com.s3.amazonaws.com - Change to:
www.code1180.com.s3-website-us-east-1.amazonaws.com - Protocol: HTTP only
- Wait 10 minutes
403 Forbidden
Causes:
- Bucket policy missing
- Files not uploaded
- Public access blocked
Fix:
# Check if files uploaded
aws s3 ls s3://www.code1180.com/
# Re-upload if empty
php artisan site:upload -v
# Verify bucket policy allows public access
Dark Mode Not Working
Cause: Browser blocking localStorage
Fix:
// Clear localStorage in browser console
localStorage.clear();
location.reload();
AWS Credentials Error
Fix:
# Verify .env has correct credentials
cat .env | grep AWS
# Should show:
# AWS_ACCESS_KEY_ID=AKIA...
# AWS_SECRET_ACCESS_KEY=...
# AWS_BUCKET=www.code1180.com
Conclusion
You now have a complete static site generator that:
✅ Converts Markdown with YAML frontmatter to SEO-optimized HTML ✅ Implements dark mode with localStorage ✅ Auto-generates navigation ✅ Deploys to AWS S3 with CloudFront CDN ✅ Serves over HTTPS with custom domain ✅ Includes comprehensive testing
Production Checklist:
- ✅ All code formatted with
vendor/bin/pint - ✅ All tests passing
- ✅ AWS credentials secure (not in git)
- ✅ CloudFront using S3 website endpoint
- ✅ SSL certificate validated and attached
- ✅ DNS pointing to CloudFront
- ✅ Bucket policy allows public read
Cost Estimate: $7-27/month for small to medium traffic blog.
Next Steps:
- Add syntax highlighting for code blocks
- Implement RSS feed generation
- Add sitemap.xml generation
- Set up CloudFront cache invalidation
- Optimize images automatically
Happy blogging! 🚀
GitHub Repository: [Coming Soon] Live Demo: https://www.code1180.com/ Author: Praveen Dias Date: January 4, 2024