Bingung pilih Laravel 12 atau Astro JS sebagai skill pertama untuk freelance? Artikel ini membahas perbandingan lengkap kedua framework dari sudut pandang freelancer pemula — mulai dari jenis projek yang sering didapat (blog, landing page, SaaS sederhana), kemudahan SEO, learning curve, hingga potensi income. Dilengkapi code examples, best/bad practices, dan case study nyata membangun blog dan SaaS sederhana dengan kedua teknologi.
Bagian 1: Realita Freelancer Pemula
Halo, saya Angga Risky Setiawan, founder BuildWithAngga — platform belajar coding dengan 900.000+ students di Indonesia.
Sebelum BuildWithAngga sebesar sekarang, saya juga mulai dari freelancer. Projek pertama saya cuma Rp 500.000 untuk bikin landing page. Waktu itu saya pakai WordPress karena itu satu-satunya yang saya bisa. Hasilnya? Lumayan lah, client happy, saya dapat uang pertama dari coding.
Dari pengalaman itu dan dari ribuan students BuildWithAngga yang sudah terjun freelance, saya paham betul struggle freelancer pemula. Pertanyaan paling sering muncul di Discord kami: "Harus belajar apa dulu?"
Dan belakangan, pertanyaan itu semakin spesifik: "Laravel atau Astro? Pilih mana dulu?"
Pertanyaan yang Salah
Sebelum jawab, saya mau luruskan dulu. Pertanyaan "mana yang lebih bagus" itu pertanyaan yang salah.
Pertanyaan yang benar adalah: "Mana yang lebih MENGHASILKAN untuk kondisi saya sekarang?"
Karena kenyataannya:
- Framework terbaik di dunia gak guna kalau gak ada yang mau bayar kamu pakai itu
- Skill paling keren gak guna kalau projek yang available gak butuh skill itu
- Belajar berbulan-bulan gak guna kalau ujung-ujungnya gak dapat client
Jadi, mari kita lihat dari kacamata yang praktis.
Projek Apa yang REALISTICALLY Bisa Didapat Pemula?
Dari data internal BuildWithAngga dan survey ke students yang sudah freelance, ini breakdown jenis projek yang didapat freelancer pemula:
Tier 1 — Paling Sering Didapat (80% projek pemula):
- Company Profile / Landing Page
- Blog / Personal Website
- Portfolio Website
- Simple catalog (tanpa payment)
Tier 2 — Lumayan Sering (15% projek pemula):
- Blog dengan CMS (client bisa update sendiri)
- Membership site sederhana
- Booking system sederhana
- Simple dashboard
Tier 3 — Jarang untuk Pemula (5% projek pemula):
- Full e-commerce dengan payment gateway
- SaaS application
- Custom CRM/ERP
- Complex web applications
Reality check: Sebagai pemula, kemungkinan besar projek pertama sampai kelima kamu ada di Tier 1. Projek kompleks seperti SaaS atau e-commerce full biasanya butuh portfolio dan track record dulu.
Apa yang Client Pedulikan?
Ini penting. Bukan apa yang KAMU anggap penting, tapi apa yang CLIENT anggap penting.
Dari ratusan projek yang saya handle dan feedback dari students:
1. "Website-nya cepat gak?"
Client mungkin gak ngerti technical, tapi mereka tau bedanya website yang loading 5 detik vs 1 detik. Kalau lambat, mereka komplain.
2. "Bisa masuk Google gak?"
Ini yang paling sering ditanya. Client mau website mereka muncul di halaman pertama Google. SEO bukan nice-to-have, tapi expectation.
3. "Bisa saya update sendiri gak?"
Client gak mau tiap ganti foto atau tambah artikel harus hubungi developer. Mereka mau bisa update sendiri.
4. "Budget saya terbatas"
Terutama untuk UMKM dan startup kecil, budget adalah constraint utama. Hosting mahal = deal breaker.
5. "Kapan selesainya?"
Timeline hampir selalu ketat. Client mau cepat, kamu harus deliver cepat.
Preview: Framework Mana untuk Projek Apa?
Sebelum deep dive, ini preview singkat:
ASTRO JS cocok untuk:
├── Landing page ✓ Perfect
├── Blog ✓ Perfect
├── Portfolio ✓ Perfect
├── Company profile ✓ Perfect
├── E-commerce catalog ✓ Good
└── SaaS application ✗ Tidak cocok
LARAVEL 12 cocok untuk:
├── Landing page ~ Overkill
├── Blog ✓ Good (tapi bisa lebih simple)
├── Portfolio ~ Overkill
├── Company profile ~ Overkill
├── E-commerce full ✓ Perfect
├── SaaS application ✓ Perfect
├── Membership site ✓ Perfect
└── Booking system ✓ Perfect
Sekarang mari kita bahas masing-masing secara detail.
Bagian 2: Mengenal Laravel 12
Apa itu Laravel?
Laravel adalah full-stack PHP framework yang sudah jadi standar industri untuk web development. Tagline-nya "The PHP Framework for Web Artisans" — framework untuk developer yang menghargai code yang elegan dan maintainable.
Laravel pertama kali rilis tahun 2011 oleh Taylor Otwell, dan sekarang sudah di versi 12 (rilis Q1 2025). Dalam 14 tahun, Laravel sudah membangun ecosystem yang sangat mature.
Arsitektur:
- MVC (Model-View-Controller)
- Eloquent ORM untuk database
- Blade templating engine
- Artisan CLI untuk productivity
Ecosystem:
- Eloquent — ORM yang beautiful untuk database operations
- Blade — Templating engine yang powerful
- Livewire — Full-stack framework untuk dynamic interfaces tanpa JavaScript
- Inertia.js — Build modern SPA dengan Vue/React tanpa API
- Sanctum/Passport — Authentication untuk API
- Horizon — Dashboard untuk Redis queues
- Telescope — Debugging dan monitoring
Laravel 12 — What's New?
Laravel 12 membawa beberapa improvement:
- Performance optimizations di core framework
- Improved starter kits dengan Livewire 3 dan Inertia 2
- Better TypeScript support untuk Inertia apps
- Enhanced queue system
- Simplified configuration
Tapi honestly, untuk freelancer pemula, perbedaan Laravel 11 vs 12 tidak terlalu signifikan. Fundamental-nya sama.
Kapan Laravel Cocok?
Laravel adalah pilihan tepat ketika projek kamu butuh:
1. Database dan CRUD Operations
// Eloquent makes database operations beautiful
$users = User::where('status', 'active')
->with('orders')
->orderBy('created_at', 'desc')
->paginate(10);
2. Authentication & Authorization
// Built-in auth scaffolding
php artisan make:auth
// Policy untuk authorization
public function update(User $user, Post $post)
{
return $user->id === $post->user_id;
}
3. Complex Business Logic
// Service class untuk business logic
class OrderService
{
public function createOrder(array $data): Order
{
return DB::transaction(function () use ($data) {
$order = Order::create($data);
$this->processPayment($order);
$this->sendNotification($order);
$this->updateInventory($order);
return $order;
});
}
}
4. Admin Panel / Dashboard
// Route group untuk admin
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
Route::resource('users', Admin\\UserController::class);
Route::resource('orders', Admin\\OrderController::class);
Route::get('dashboard', [Admin\\DashboardController::class, 'index']);
});
5. API Development
// RESTful API dengan Laravel
Route::apiResource('products', ProductController::class);
// API Response
return response()->json([
'data' => ProductResource::collection($products),
'meta' => ['total' => $products->total()]
]);
Laravel untuk Freelancer — Honest Assessment
Projek yang Cocok:
- SaaS applications (invoice app, project management, dll)
- E-commerce dengan payment gateway
- Membership/subscription sites
- Booking systems (hotel, salon, clinic)
- Custom CRM/ERP
- Multi-vendor marketplaces
Learning Curve: Medium-High
Laravel punya banyak konsep yang harus dipahami:
- MVC architecture
- Routing system
- Eloquent ORM dan relationships
- Blade templating
- Middleware
- Authentication & authorization
- Database migrations
- Queue system
Untuk benar-benar produktif dengan Laravel, butuh waktu 1-3 bulan belajar intensif.
Setup Time: Longer
Projek Laravel butuh setup lebih banyak:
- Server dengan PHP
- Database (MySQL/PostgreSQL)
- Composer dependencies
- Environment configuration
- Optional: Redis, queue worker
Income Potential: Higher per Project
Karena projek Laravel biasanya lebih kompleks, rate-nya juga lebih tinggi:
- Simple blog dengan CMS: Rp 5-10 juta
- E-commerce: Rp 15-50 juta
- SaaS MVP: Rp 20-100 juta
Competition: More Developers
Laravel sudah mainstream, jadi competition lebih ketat. Tapi demand juga tinggi.
Kelebihan Laravel
- Full-stack Solution — Semua yang kamu butuhkan sudah ada
- Huge Ecosystem — Package untuk hampir semua kebutuhan
- Great Documentation — Docs Laravel adalah salah satu yang terbaik
- Strong Community Indonesia — Banyak developer Laravel di Indonesia
- Long-term Maintainability — Code terstruktur, mudah di-maintain
- Job Market — Banyak job opening untuk Laravel developer
Kekurangan Laravel
- Overkill untuk Static Sites — Pakai Laravel untuk landing page itu seperti pakai truk untuk belanja ke warung
- Butuh Server — Hosting cost lebih tinggi ($5-20/month minimum)
- SEO Butuh Extra Effort — Harus setup meta tags, sitemap, dll manual
- Slower Initial Load — Butuh server processing, tidak secepat static HTML
- Learning Curve Steep — Butuh waktu untuk produktif
Code Example: Simple Blog di Laravel
// routes/web.php
use App\\Http\\Controllers\\BlogController;
Route::get('/', [BlogController::class, 'index'])->name('home');
Route::get('/blog', [BlogController::class, 'index'])->name('blog.index');
Route::get('/blog/{post:slug}', [BlogController::class, 'show'])->name('blog.show');
// app/Models/Post.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
class Post extends Model
{
protected $fillable = [
'title',
'slug',
'excerpt',
'content',
'featured_image',
'author_id',
'category_id',
'is_published',
'published_at',
'meta_title',
'meta_description',
];
protected $casts = [
'is_published' => 'boolean',
'published_at' => 'datetime',
];
public function scopePublished($query)
{
return $query->where('is_published', true)
->where('published_at', '<=', now());
}
public function author(): BelongsTo
{
return $this->belongsTo(User::class, 'author_id');
}
public function category(): BelongsTo
{
return $this->belongsTo(Category::class);
}
}
// app/Http/Controllers/BlogController.php
namespace App\\Http\\Controllers;
use App\\Models\\Post;
use Illuminate\\View\\View;
class BlogController extends Controller
{
public function index(): View
{
$posts = Post::published()
->with(['author', 'category'])
->latest('published_at')
->paginate(10);
return view('blog.index', compact('posts'));
}
public function show(Post $post): View
{
// Pastikan post sudah published
abort_unless($post->is_published, 404);
// SEO data
$seo = [
'title' => $post->meta_title ?? $post->title,
'description' => $post->meta_description ?? $post->excerpt,
'image' => $post->featured_image,
'url' => route('blog.show', $post),
];
// Related posts
$relatedPosts = Post::published()
->where('category_id', $post->category_id)
->where('id', '!=', $post->id)
->limit(3)
->get();
return view('blog.show', compact('post', 'seo', 'relatedPosts'));
}
}
{{-- resources/views/blog/show.blade.php --}}
@extends('layouts.app')
@section('meta')
<title>{{ $seo['title'] }} | My Blog</title>
<meta name="description" content="{{ $seo['description'] }}">
<link rel="canonical" href="{{ $seo['url'] }}">
{{-- Open Graph --}}
<meta property="og:type" content="article">
<meta property="og:title" content="{{ $seo['title'] }}">
<meta property="og:description" content="{{ $seo['description'] }}">
<meta property="og:image" content="{{ asset($seo['image']) }}">
<meta property="og:url" content="{{ $seo['url'] }}">
{{-- Twitter --}}
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{{ $seo['title'] }}">
<meta name="twitter:description" content="{{ $seo['description'] }}">
<meta name="twitter:image" content="{{ asset($seo['image']) }}">
@endsection
@section('content')
<article class="max-w-3xl mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-4xl font-bold mb-4">{{ $post->title }}</h1>
<div class="flex items-center text-gray-600 text-sm">
<span>{{ $post->author->name }}</span>
<span class="mx-2">•</span>
<time datetime="{{ $post->published_at->toISOString() }}">
{{ $post->published_at->format('d M Y') }}
</time>
<span class="mx-2">•</span>
<span>{{ $post->category->name }}</span>
</div>
</header>
@if($post->featured_image)
<img
src="{{ asset($post->featured_image) }}"
alt="{{ $post->title }}"
class="w-full rounded-lg mb-8"
>
@endif
<div class="prose prose-lg max-w-none">
{!! $post->content !!}
</div>
</article>
@if($relatedPosts->count() > 0)
<section class="max-w-3xl mx-auto px-4 py-8 border-t">
<h2 class="text-2xl font-bold mb-6">Artikel Terkait</h2>
<div class="grid md:grid-cols-3 gap-6">
@foreach($relatedPosts as $related)
<a href="{{ route('blog.show', $related) }}" class="block">
<h3 class="font-semibold hover:text-blue-600">
{{ $related->title }}
</h3>
</a>
@endforeach
</div>
</section>
@endif
@endsection
Perhatikan berapa banyak code yang dibutuhkan untuk blog sederhana di Laravel. Ini belum termasuk admin panel untuk manage posts. Untuk blog doang, ini memang overkill — tapi powerful kalau nanti mau expand ke fitur yang lebih kompleks.
Bagian 3: Mengenal Astro JS
Apa itu Astro?
Astro adalah web framework yang dirancang khusus untuk content-driven websites. Tagline-nya: "The web framework for content-driven websites."
Berbeda dengan Laravel yang full-stack, Astro adalah Static Site Generator (SSG) yang menghasilkan HTML statis. Hasilnya? Website yang super cepat dan SEO-friendly by default.
Astro pertama kali rilis tahun 2021 dan dengan cepat menjadi favorit untuk:
- Blog dan content sites
- Marketing websites
- Documentation sites
- Portfolio websites
- Landing pages
Filosofi Astro
Astro dibangun dengan beberapa prinsip yang revolutionary:
1. Content-First
Astro dirancang untuk websites yang fokusnya adalah content — artikel, gambar, video. Bukan aplikasi interaktif.
2. Zero JavaScript by Default
Ini yang bikin Astro beda. By default, Astro tidak mengirim JavaScript ke browser. Hasilnya? Website yang super ringan dan cepat.
---
// Ini code JavaScript yang HANYA jalan di build time
// Browser tidak akan pernah lihat code ini
const posts = await fetch('/api/posts').then(r => r.json());
---
<!-- Ini pure HTML yang dikirim ke browser -->
<ul>
{posts.map(post => (
<li>{post.title}</li>
))}
</ul>
3. Island Architecture
Butuh interaktivitas? Astro punya konsep "Islands" — komponen interaktif yang terisolasi di lautan HTML statis.
---
import StaticHeader from '../components/Header.astro';
import InteractiveCarousel from '../components/Carousel.jsx';
---
<!-- Static, no JS -->
<StaticHeader />
<!-- Interactive, loads React only untuk komponen ini -->
<InteractiveCarousel client:visible />
<!-- Rest of page is static -->
<footer>Static footer</footer>
4. Framework Agnostic
Astro bisa pakai komponen dari React, Vue, Svelte, Solid, bahkan sekaligus dalam satu project!
---
import ReactButton from '../components/Button.jsx';
import VueCard from '../components/Card.vue';
import SvelteForm from '../components/Form.svelte';
---
<ReactButton />
<VueCard />
<SvelteForm />
Kapan Astro Cocok?
Astro adalah pilihan perfect untuk:
1. Blog dan Content Websites
---
// src/pages/blog/[slug].astro
import { getCollection } from 'astro:content';
import Layout from '../../layouts/Layout.astro';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map(post => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
---
<Layout title={post.data.title}>
<article>
<h1>{post.data.title}</h1>
<Content />
</article>
</Layout>
2. Marketing Sites dan Landing Pages
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import Hero from '../components/Hero.astro';
import Features from '../components/Features.astro';
import Testimonials from '../components/Testimonials.astro';
import CTA from '../components/CTA.astro';
---
<Layout title="Grow Your Business" description="The best solution for...">
<Hero />
<Features />
<Testimonials />
<CTA />
</Layout>
3. Documentation Sites
---
// Built-in support untuk Markdown/MDX
import { getCollection } from 'astro:content';
const docs = await getCollection('docs');
const sortedDocs = docs.sort((a, b) => a.data.order - b.data.order);
---
<nav>
{sortedDocs.map(doc => (
<a href={`/docs/${doc.slug}`}>{doc.data.title}</a>
))}
</nav>
4. Portfolio Websites
5. Company Profiles
6. Static E-commerce (Catalog)
Astro untuk Freelancer — Honest Assessment
Projek yang Cocok:
- Landing page / one-page websites
- Blog pribadi atau company blog
- Portfolio website
- Company profile
- Documentation sites
- Marketing websites
- Catalog tanpa checkout
Learning Curve: Low-Medium
Kalau kamu sudah paham HTML, CSS, dan sedikit JavaScript, Astro akan terasa natural. Syntax-nya intuitif:
---
// JavaScript di sini (build time only)
const name = "World";
const items = ["Apple", "Banana", "Orange"];
---
<!-- HTML dengan expressions -->
<h1>Hello {name}!</h1>
<ul>
{items.map(item => <li>{item}</li>)}
</ul>
<style>
/* Scoped CSS - hanya berlaku untuk komponen ini */
h1 {
color: purple;
}
</style>
Butuh waktu 1-2 minggu untuk produktif dengan Astro.
Setup Time: Fast
# Create new project
npm create astro@latest my-blog
# Install dependencies
cd my-blog
npm install
# Start development
npm run dev
# Build for production
npm run build
Done. Tidak perlu setup server, database, atau konfigurasi rumit.
Income Potential: Medium per Project, Higher Volume
- Landing page: Rp 1-3 juta
- Blog sederhana: Rp 2-5 juta
- Company profile: Rp 3-7 juta
- Portfolio: Rp 1-3 juta
Karena development lebih cepat, kamu bisa handle lebih banyak projek per bulan.
Competition: Fewer Developers
Astro masih relatif baru, jadi competition belum sepadat Laravel atau WordPress. Ini opportunity!
Kelebihan Astro
1. Blazing Fast (100 Lighthouse Score)
Karena output-nya static HTML tanpa JavaScript bloat, website Astro hampir selalu dapat score 100 di Lighthouse.
2. SEO-Friendly Out of the Box
- Static HTML = Google bisa crawl dengan mudah
- Built-in sitemap generation
- Built-in image optimization
- Fast load time = better ranking
3. Simple Deployment
npm run build
# Output di folder dist/
# Upload ke hosting manapun
Bisa deploy ke:
- Netlify (free)
- Vercel (free)
- Cloudflare Pages (free)
- GitHub Pages (free)
- Any static hosting
4. Low/Free Hosting Cost
Karena static files, hosting bisa gratis! Netlify, Vercel, dan Cloudflare Pages punya free tier yang generous.
5. Great Developer Experience
- Hot reload yang instant
- TypeScript support built-in
- Great error messages
- VSCode extension yang bagus
Kekurangan Astro
1. Limited Dynamic Features
Astro tidak punya:
- Built-in database
- Built-in authentication
- Server-side processing (by default)
Untuk fitur dinamis, butuh external services.
2. Butuh External Services untuk Backend
Mau user login? Pakai Auth0, Clerk, atau Supabase. Mau database? Pakai Supabase, Firebase, atau API external. Mau payment? Pakai Stripe, Midtrans API.
3. Tidak Cocok untuk Complex Apps
SaaS, e-commerce dengan checkout, aplikasi dengan banyak user interaction — bukan domain Astro.
4. Smaller Ecosystem
Dibanding Laravel yang sudah 14 tahun, ecosystem Astro masih growing.
5. Build Step Required
Setiap perubahan content butuh rebuild. Untuk blog dengan update harian, ini bisa jadi friction (meskipun ada solusinya dengan CMS).
Code Example: Simple Blog di Astro
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
import mdx from '@astrojs/mdx';
export default defineConfig({
site: '<https://myblog.com>',
integrations: [
sitemap(),
mdx(),
],
});
---
// src/layouts/Layout.astro
interface Props {
title: string;
description: string;
image?: string;
}
const { title, description, image = '/default-og.jpg' } = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
---
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- SEO -->
<title>{title}</title>
<meta name="description" content={description}>
<link rel="canonical" href={canonicalURL}>
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:image" content={new URL(image, Astro.site)}>
<meta property="og:url" content={canonicalURL}>
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content={title}>
<meta name="twitter:description" content={description}>
<meta name="twitter:image" content={new URL(image, Astro.site)}>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<slot />
</body>
</html>
<style is:global>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
line-height: 1.6;
}
</style>
---
// src/pages/index.astro
import Layout from '../layouts/Layout.astro';
import PostCard from '../components/PostCard.astro';
import { getCollection } from 'astro:content';
const posts = await getCollection('blog');
const sortedPosts = posts
.filter(post => !post.data.draft)
.sort((a, b) => b.data.publishedAt.valueOf() - a.data.publishedAt.valueOf());
---
<Layout
title="My Blog"
description="Welcome to my blog about web development"
>
<main class="container">
<h1>Latest Posts</h1>
<div class="posts-grid">
{sortedPosts.map((post) => (
<PostCard post={post} />
))}
</div>
</main>
</Layout>
<style>
.container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
h1 {
font-size: 2.5rem;
margin-bottom: 2rem;
}
.posts-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 2rem;
}
</style>
---
// src/components/PostCard.astro
interface Props {
post: {
slug: string;
data: {
title: string;
excerpt: string;
publishedAt: Date;
image?: string;
};
};
}
const { post } = Astro.props;
const { title, excerpt, publishedAt, image } = post.data;
---
<article class="card">
{image && (
<img src={image} alt={title} class="card-image" />
)}
<div class="card-content">
<time datetime={publishedAt.toISOString()}>
{publishedAt.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
<h2>
<a href={`/blog/${post.slug}`}>{title}</a>
</h2>
<p>{excerpt}</p>
</div>
</article>
<style>
.card {
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s;
}
.card:hover {
transform: translateY(-4px);
}
.card-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.card-content {
padding: 1.5rem;
}
time {
color: #666;
font-size: 0.875rem;
}
h2 {
margin: 0.5rem 0;
font-size: 1.25rem;
}
h2 a {
color: inherit;
text-decoration: none;
}
h2 a:hover {
color: #0066cc;
}
p {
color: #444;
font-size: 0.95rem;
}
</style>
---
// src/pages/blog/[slug].astro
import Layout from '../../layouts/Layout.astro';
import { getCollection } from 'astro:content';
export async function getStaticPaths() {
const posts = await getCollection('blog');
return posts.map((post) => ({
params: { slug: post.slug },
props: { post },
}));
}
const { post } = Astro.props;
const { Content } = await post.render();
const { title, excerpt, publishedAt, image, author } = post.data;
---
<Layout
title={title}
description={excerpt}
image={image}
>
<article class="post">
<header>
<h1>{title}</h1>
<div class="meta">
<span>{author}</span>
<span>•</span>
<time datetime={publishedAt.toISOString()}>
{publishedAt.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
</header>
{image && (
<img src={image} alt={title} class="featured-image" />
)}
<div class="content">
<Content />
</div>
</article>
</Layout>
<style>
.post {
max-width: 720px;
margin: 0 auto;
padding: 2rem;
}
header {
margin-bottom: 2rem;
}
h1 {
font-size: 2.5rem;
line-height: 1.2;
margin-bottom: 1rem;
}
.meta {
color: #666;
font-size: 0.95rem;
}
.meta span {
margin-right: 0.5rem;
}
.featured-image {
width: 100%;
border-radius: 8px;
margin-bottom: 2rem;
}
.content {
font-size: 1.125rem;
line-height: 1.8;
}
.content :global(h2) {
margin-top: 2rem;
margin-bottom: 1rem;
}
.content :global(p) {
margin-bottom: 1.5rem;
}
.content :global(code) {
background: #f4f4f4;
padding: 0.2em 0.4em;
border-radius: 4px;
}
</style>
// src/content/blog/first-post.md
---
title: "Memulai Karir Freelance Web Developer"
excerpt: "Panduan lengkap untuk memulai karir sebagai freelance web developer di Indonesia"
publishedAt: 2025-01-15
author: "Angga Risky"
image: "/images/blog/freelance-guide.jpg"
draft: false
---
# Memulai Karir Freelance Web Developer
Menjadi freelance web developer adalah salah satu pilihan karir terbaik di era digital...
## Kenapa Freelance?
Ada beberapa alasan kenapa freelance menarik:
1. **Fleksibilitas waktu** - Kamu yang tentukan jam kerja
2. **Unlimited income** - Tidak ada ceiling untuk penghasilan
3. **Pilih projek sendiri** - Kerja dengan client yang kamu suka
## Skill yang Dibutuhkan
Untuk memulai, kamu minimal harus menguasai:
- HTML & CSS
- JavaScript dasar
- Satu framework (Laravel, Astro, dll)
- Git untuk version control
## Tips Mendapatkan Client Pertama
...
Perhatikan betapa simple code Astro dibanding Laravel untuk blog yang sama. Dan hasilnya? Website yang lebih cepat dan SEO-friendly by default.
Bagian 4: Perbandingan Head-to-Head
Sekarang mari kita bandingkan Laravel 12 dan Astro JS secara langsung.
Comparison Table
┌─────────────────────────┬─────────────────────────┬─────────────────────────┐
│ Aspek │ Laravel 12 │ Astro JS │
├─────────────────────────┼─────────────────────────┼─────────────────────────┤
│ Type │ Full-stack Framework │ Static Site Generator │
│ Language │ PHP + Blade │ JavaScript/TypeScript │
│ Output │ Dynamic (server-side) │ Static HTML │
│ Learning Curve │ Medium-High (1-3 bulan) │ Low-Medium (1-2 minggu) │
│ SEO │ Good (butuh setup) │ Excellent (default) │
│ Performance │ Good (with optimization)│ Excellent (default) │
│ Lighthouse Score │ 70-85 (default) │ 95-100 (default) │
│ Dynamic Content │ ✓ Built-in │ Limited (external API) │
│ Database │ ✓ Eloquent ORM │ ✗ External service │
│ Authentication │ ✓ Built-in │ ✗ External service │
│ Hosting Cost │ $5-20/month │ $0-5/month (free tier) │
│ Hosting Type │ PHP server required │ Static hosting (CDN) │
│ Build Process │ None (runtime) │ Build step required │
│ Deployment Complexity │ Medium │ Easy │
│ Ecosystem Size │ Very Large │ Growing │
│ Job Market Indonesia │ Large │ Small (tapi growing) │
│ Best For │ Web apps, SaaS, API │ Content sites, blogs │
└─────────────────────────┴─────────────────────────┴─────────────────────────┘
Performance Comparison
Laravel Performance (tanpa optimization):
First Request (cold): 300-800ms
Subsequent Requests: 100-300ms
With Redis Cache: 50-150ms
Time to First Byte: 100-400ms
Lighthouse Performance: 65-80
Laravel Performance (with full optimization):
First Request: 150-300ms
With Full Page Cache: 30-80ms
Time to First Byte: 50-150ms
Lighthouse Performance: 80-92
Astro Performance (default):
Every Request: 10-50ms (static file)
Time to First Byte: 10-30ms
Lighthouse Performance: 95-100
Perbedaannya signifikan. Astro menang telak untuk performance karena sifatnya yang static.
SEO Comparison
Laravel SEO — Manual Setup Required:
// Harus install package
composer require artesaos/seotools
// Harus setup di setiap controller
use Artesaos\\SEOTools\\Facades\\SEOMeta;
use Artesaos\\SEOTools\\Facades\\OpenGraph;
public function show(Post $post)
{
SEOMeta::setTitle($post->title);
SEOMeta::setDescription($post->excerpt);
SEOMeta::setCanonical(route('posts.show', $post));
OpenGraph::setTitle($post->title);
OpenGraph::setDescription($post->excerpt);
OpenGraph::setUrl(route('posts.show', $post));
OpenGraph::addImage($post->featured_image);
return view('posts.show', compact('post'));
}
// Harus generate sitemap
php artisan sitemap:generate
// Harus setup robots.txt manual
// Harus optimize images manual atau install package
// Harus setup lazy loading
// Harus minify assets
Astro SEO — Mostly Built-in:
---
// SEO sudah terintegrasi di layout
const { title, description, image } = Astro.props;
---
<head>
<title>{title}</title>
<meta name="description" content={description}>
<meta property="og:title" content={title}>
<!-- Done! -->
</head>
// Sitemap otomatis dengan 1 line config
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: '<https://example.com>',
integrations: [sitemap()], // Auto-generate sitemap
});
---
// Image optimization built-in
import { Image } from 'astro:assets';
import myImage from '../images/photo.jpg';
---
<!-- Otomatis optimize, lazy load, proper sizing -->
<Image src={myImage} alt="Description" />
Lighthouse SEO Score Comparison:
Metric │ Laravel (default) │ Laravel (optimized) │ Astro (default)
──────────────────┼───────────────────┼─────────────────────┼────────────────
Performance │ 65-75 │ 80-90 │ 95-100
Accessibility │ 85-90 │ 90-95 │ 90-100
Best Practices │ 80-85 │ 90-95 │ 95-100
SEO │ 80-90 │ 90-100 │ 95-100
Development Speed Comparison
Waktu Development untuk Berbagai Projek:
Projek │ Laravel │ Astro
──────────────────────────┼────────────────┼──────────────────
Landing Page (1 page) │ 1-2 hari │ 0.5-1 hari
Blog Sederhana (5 pages) │ 3-5 hari │ 1-2 hari
Blog dengan Admin CMS │ 5-8 hari │ 2-3 hari (headless)
Company Profile (10 pages)│ 3-5 hari │ 1-3 hari
Portfolio Website │ 2-4 hari │ 1-2 hari
E-commerce Catalog │ 5-7 hari │ 3-5 hari
Full E-commerce │ 2-4 minggu │ ✗ Tidak cocok
SaaS MVP │ 3-6 minggu │ ✗ Tidak cocok
Hosting Cost Comparison
Laravel Hosting Options:
Shared Hosting (Niagahoster, dll):
├── Cost: Rp 50-150K/bulan
├── Pros: Murah
└── Cons: Slow, limited resources
VPS (DigitalOcean, Vultr):
├── Cost: $5-20/bulan (~Rp 80-320K)
├── Pros: Full control, scalable
└── Cons: Harus manage server sendiri
Managed (Laravel Forge + DO):
├── Cost: $12 + $6 = $18/bulan (~Rp 290K)
├── Pros: Easy deployment
└── Cons: Lebih mahal
PaaS (Railway, Render):
├── Cost: $5-25/bulan
├── Pros: Zero DevOps
└── Cons: Bisa mahal untuk traffic tinggi
Astro Hosting Options:
Netlify Free:
├── Cost: $0
├── Includes: 100GB bandwidth, SSL, CDN
└── Perfect for most projects
Vercel Free:
├── Cost: $0
├── Includes: 100GB bandwidth, SSL, CDN
└── Great performance
Cloudflare Pages Free:
├── Cost: $0
├── Includes: Unlimited bandwidth, SSL, CDN
└── Best free option
Pro Plans (jika butuh lebih):
├── Cost: $19-25/bulan
└── Rarely needed untuk static sites
Annual Cost Comparison untuk Blog:
│ Laravel │ Astro
────────────────────┼──────────────────┼──────────────
Hosting │ Rp 1-3.6 juta │ Rp 0
Domain │ Rp 150K │ Rp 150K
SSL │ Rp 0-500K │ Rp 0 (included)
────────────────────┼──────────────────┼──────────────
TOTAL/tahun │ Rp 1.15-4.25 juta│ Rp 150K
Selisihnya bisa Rp 1-4 juta per tahun per projek. Untuk freelancer yang handle 10 projek, itu penghematan signifikan yang bisa di-pass ke client atau jadi profit tambahan.
Maintenance Comparison
Laravel Maintenance:
Regular Tasks:
├── Server updates (OS, PHP, extensions)
├── Laravel & package updates
├── Security patches
├── Database backups
├── SSL certificate renewal (jika manual)
├── Log management
├── Performance monitoring
└── Server resource monitoring
Time: 2-4 jam/bulan per project
Risk: Server down, security vulnerabilities
Astro Maintenance:
Regular Tasks:
├── Astro & package updates (optional)
├── Content updates (jika perlu)
└── ... that's it
Time: 30 menit/bulan per project
Risk: Minimal (static files)
When to Choose What — Quick Decision
┌─────────────────────────────────────────────────────────────────┐
│ DECISION FLOWCHART │
└─────────────────────────────────────────────────────────────────┘
Butuh user login/registration?
├── YES → Laravel
└── NO ↓
Butuh database untuk simpan data user?
├── YES → Laravel
└── NO ↓
Butuh payment/checkout?
├── YES → Laravel
└── NO ↓
Butuh admin dashboard yang complex?
├── YES → Laravel
└── NO ↓
Content-focused (blog, marketing, portfolio)?
├── YES → Astro
└── NO ↓
Butuh SEO maksimal dengan effort minimal?
├── YES → Astro
└── NO ↓
Budget hosting terbatas / mau free?
├── YES → Astro
└── NO → Either works, pilih based on familiarity
Real Talk: Kombinasi Keduanya
Sebenarnya, Laravel dan Astro bukan kompetitor langsung — mereka complementary.
Arsitektur Hybrid:
┌─────────────────────────────────────────────────────────────────┐
│ USER │
└───────────────────────────┬─────────────────────────────────────┘
│
┌───────────────────┴───────────────────┐
▼ ▼
┌───────────────────┐ ┌───────────────────┐
│ ASTRO FRONTEND │ │ LARAVEL API │
│ │ ──── API ────▶ │ │
│ • Marketing pages │ │ • Authentication │
│ • Blog │ │ • Database CRUD │
│ • Static content │ │ • Business logic │
│ • SEO-optimized │ │ • Payment │
│ │ ◀──── JSON ──── │ │
└───────────────────┘ └───────────────────┘
│ │
▼ ▼
Netlify DigitalOcean
(Free) ($6-12/mo)
Contoh Use Case:
Website SaaS dengan:
- Landing page (Astro) — SEO perfect, super cepat
- Blog (Astro) — Content marketing, SEO optimized
- Documentation (Astro) — Static, fast
- App dashboard (Laravel) — Dynamic, authenticated
- API (Laravel) — Business logic, database
Ini best of both worlds. Marketing pages dapat score SEO 100, sementara app tetap powerful dengan Laravel.
Di bagian selanjutnya, kita akan deep dive ke SEO — kenapa ini penting untuk freelancer, dan bagaimana masing-masing framework handle SEO. Lalu kita akan masuk ke case study nyata: membangun Blog dan SaaS sederhana dengan kedua framework.
Bagian 5: SEO Deep Dive — Mana yang Lebih Baik?
Kenapa SEO Penting untuk Freelancer?
Sebelum masuk ke technical comparison, mari kita bahas kenapa SEO itu crucial untuk freelancer.
1. Client SELALU Tanya Soal SEO
Dari pengalaman saya dan feedback students BuildWithAngga, hampir setiap client akan tanya:
- "Website-nya bisa masuk Google gak?"
- "Bisa ranking di halaman pertama?"
- "Kompetitor saya sudah di page 1, saya juga mau"
Kalau kamu jawab "bisa, website saya SEO-friendly by default" — itu selling point yang kuat.
2. SEO-Friendly = Premium Pricing
Freelancer yang bisa deliver website dengan Lighthouse score 90+ bisa charge lebih mahal. Ini bukan cuma soal "hijau di PageSpeed" — ini soal business results untuk client.
3. Repeat Business
Website yang ranking bagus di Google = client happy = referral + maintenance contract. Saya punya beberapa students yang dapat recurring income dari SEO maintenance aja.
4. Portfolio yang Berbicara
Ketika pitching ke client baru, kamu bisa tunjukkan: "Ini website yang saya buat, Lighthouse score-nya 98, sudah ranking di Google untuk keyword X." That's powerful.
Core Web Vitals — Metrik yang Google Pedulikan
Google menggunakan Core Web Vitals sebagai ranking factor. Ini metrik-metriknya:
LCP (Largest Contentful Paint)
├── Apa: Waktu sampai konten utama terlihat
├── Target: < 2.5 detik
└── Impact: User experience, bounce rate
FID (First Input Delay) / INP (Interaction to Next Paint)
├── Apa: Waktu sampai halaman responsive terhadap input
├── Target: < 100ms (FID) / < 200ms (INP)
└── Impact: Interactivity, user frustration
CLS (Cumulative Layout Shift)
├── Apa: Seberapa banyak layout "loncat" saat loading
├── Target: < 0.1
└── Impact: Visual stability, accidental clicks
TTFB (Time to First Byte)
├── Apa: Waktu sampai server mulai respond
├── Target: < 800ms (ideal < 200ms)
└── Impact: Overall speed perception
Laravel SEO: Good, Tapi Butuh Effort
Laravel bisa SEO-friendly, tapi butuh setup manual. Mari kita lihat apa saja yang harus dilakukan.
Step 1: Install SEO Package
composer require artesaos/seotools
php artisan vendor:publish --provider="Artesaos\\SEOTools\\Providers\\SEOToolsServiceProvider"
Step 2: Setup Meta Tags di Setiap Controller
// app/Http/Controllers/BlogController.php
use Artesaos\\SEOTools\\Facades\\SEOMeta;
use Artesaos\\SEOTools\\Facades\\OpenGraph;
use Artesaos\\SEOTools\\Facades\\TwitterCard;
use Artesaos\\SEOTools\\Facades\\JsonLd;
class BlogController extends Controller
{
public function show(Post $post)
{
// Basic Meta
SEOMeta::setTitle($post->title);
SEOMeta::setDescription($post->excerpt);
SEOMeta::setCanonical(route('blog.show', $post));
SEOMeta::addKeyword($post->tags->pluck('name')->toArray());
// Open Graph (Facebook, LinkedIn)
OpenGraph::setTitle($post->title);
OpenGraph::setDescription($post->excerpt);
OpenGraph::setUrl(route('blog.show', $post));
OpenGraph::addImage(asset($post->featured_image));
OpenGraph::setType('article');
OpenGraph::setArticle([
'published_time' => $post->published_at->toIso8601String(),
'modified_time' => $post->updated_at->toIso8601String(),
'author' => $post->author->name,
'section' => $post->category->name,
]);
// Twitter Card
TwitterCard::setTitle($post->title);
TwitterCard::setDescription($post->excerpt);
TwitterCard::setImage(asset($post->featured_image));
TwitterCard::setType('summary_large_image');
// JSON-LD Structured Data
JsonLd::setType('Article');
JsonLd::setTitle($post->title);
JsonLd::setDescription($post->excerpt);
JsonLd::setImage(asset($post->featured_image));
JsonLd::addValue('author', [
'@type' => 'Person',
'name' => $post->author->name,
]);
JsonLd::addValue('datePublished', $post->published_at->toIso8601String());
JsonLd::addValue('dateModified', $post->updated_at->toIso8601String());
return view('blog.show', compact('post'));
}
}
Step 3: Generate Sitemap
composer require spatie/laravel-sitemap
// app/Console/Commands/GenerateSitemap.php
use Spatie\\Sitemap\\Sitemap;
use Spatie\\Sitemap\\Tags\\Url;
class GenerateSitemap extends Command
{
protected $signature = 'sitemap:generate';
public function handle()
{
$sitemap = Sitemap::create();
// Static pages
$sitemap->add(Url::create('/')->setPriority(1.0));
$sitemap->add(Url::create('/about')->setPriority(0.8));
$sitemap->add(Url::create('/contact')->setPriority(0.8));
// Blog posts
Post::published()->each(function (Post $post) use ($sitemap) {
$sitemap->add(
Url::create("/blog/{$post->slug}")
->setLastModificationDate($post->updated_at)
->setPriority(0.9)
->setChangeFrequency('weekly')
);
});
// Categories
Category::each(function (Category $category) use ($sitemap) {
$sitemap->add(
Url::create("/category/{$category->slug}")
->setPriority(0.7)
);
});
$sitemap->writeToFile(public_path('sitemap.xml'));
$this->info('Sitemap generated!');
}
}
Step 4: Setup robots.txt
// public/robots.txt
User-agent: *
Allow: /
Disallow: /admin/
Disallow: /api/
Sitemap: <https://example.com/sitemap.xml>
Step 5: Image Optimization
composer require spatie/laravel-medialibrary
# atau
composer require intervention/image
// Manual image optimization
use Intervention\\Image\\Facades\\Image;
public function uploadImage(Request $request)
{
$image = Image::make($request->file('image'));
// Resize untuk web
$image->resize(1200, null, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
// Optimize quality
$image->encode('webp', 80);
$path = 'images/' . uniqid() . '.webp';
Storage::put($path, $image->stream());
return $path;
}
Step 6: Lazy Loading
{{-- Blade view --}}
<img
src="{{ asset($post->featured_image) }}"
alt="{{ $post->title }}"
loading="lazy"
decoding="async"
>
Step 7: Caching untuk Speed
// Cache halaman untuk guests
Route::middleware('cache.headers:public;max_age=3600')->group(function () {
Route::get('/blog', [BlogController::class, 'index']);
Route::get('/blog/{post:slug}', [BlogController::class, 'show']);
});
// Query caching
public function index()
{
$posts = Cache::remember('blog.posts', 3600, function () {
return Post::published()
->with(['author', 'category'])
->latest()
->paginate(10);
});
return view('blog.index', compact('posts'));
}
Total Setup Time untuk SEO Proper di Laravel: 4-8 jam
Dan ini belum termasuk fine-tuning dan monitoring. Lighthouse score typical setelah semua ini: 80-92.
Astro SEO: Excellent by Default
Sekarang bandingkan dengan Astro.
Step 1: Config Sitemap (1 menit)
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: '<https://example.com>',
integrations: [sitemap()],
});
Done. Sitemap otomatis di-generate saat build.
Step 2: Layout dengan SEO (sudah built-in)
---
// src/layouts/Layout.astro
interface Props {
title: string;
description: string;
image?: string;
article?: {
publishedTime: Date;
modifiedTime?: Date;
author: string;
section?: string;
tags?: string[];
};
}
const {
title,
description,
image = '/default-og.jpg',
article
} = Astro.props;
const canonicalURL = new URL(Astro.url.pathname, Astro.site);
const imageURL = new URL(image, Astro.site);
---
<!DOCTYPE html>
<html lang="id">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<!-- Primary Meta -->
<title>{title}</title>
<meta name="description" content={description}>
<link rel="canonical" href={canonicalURL}>
<!-- Open Graph -->
<meta property="og:type" content={article ? 'article' : 'website'}>
<meta property="og:title" content={title}>
<meta property="og:description" content={description}>
<meta property="og:image" content={imageURL}>
<meta property="og:url" content={canonicalURL}>
<meta property="og:site_name" content="My Blog">
{article && (
<>
<meta property="article:published_time" content={article.publishedTime.toISOString()}>
{article.modifiedTime && (
<meta property="article:modified_time" content={article.modifiedTime.toISOString()}>
)}
<meta property="article:author" content={article.author}>
{article.section && <meta property="article:section" content={article.section}>}
{article.tags?.map(tag => <meta property="article:tag" content={tag}>)}
</>
)}
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content={title}>
<meta name="twitter:description" content={description}>
<meta name="twitter:image" content={imageURL}>
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
</head>
<body>
<slot />
</body>
</html>
Step 3: Image Optimization (Built-in)
---
import { Image } from 'astro:assets';
import heroImage from '../images/hero.jpg';
---
<!-- Astro otomatis:
- Convert ke WebP/AVIF
- Generate responsive sizes
- Add lazy loading
- Prevent CLS dengan aspect ratio
-->
<Image
src={heroImage}
alt="Hero image"
widths={[400, 800, 1200]}
sizes="(max-width: 800px) 100vw, 800px"
/>
Step 4: Structured Data (Optional tapi easy)
---
// src/components/ArticleSchema.astro
interface Props {
title: string;
description: string;
image: string;
publishedAt: Date;
author: string;
}
const { title, description, image, publishedAt, author } = Astro.props;
const schema = {
"@context": "<https://schema.org>",
"@type": "Article",
"headline": title,
"description": description,
"image": image,
"datePublished": publishedAt.toISOString(),
"author": {
"@type": "Person",
"name": author
}
};
---
<script type="application/ld+json" set:html={JSON.stringify(schema)} />
Total Setup Time untuk SEO di Astro: 30-60 menit
Dan hasilnya? Lighthouse score 95-100 out of the box.
Real Lighthouse Comparison
Saya buat test dengan blog sederhana yang sama — 10 posts, 5 categories, homepage dengan listing.
┌─────────────────────┬───────────────────┬───────────────────┐
│ Metric │ Laravel │ Astro │
│ │ (with setup) │ (default) │
├─────────────────────┼───────────────────┼───────────────────┤
│ Performance │ 78 │ 100 │
│ Accessibility │ 92 │ 98 │
│ Best Practices │ 92 │ 100 │
│ SEO │ 92 │ 100 │
├─────────────────────┼───────────────────┼───────────────────┤
│ LCP │ 1.8s │ 0.6s │
│ FID │ 45ms │ 8ms │
│ CLS │ 0.05 │ 0 │
│ TTFB │ 320ms │ 45ms │
├─────────────────────┼───────────────────┼───────────────────┤
│ Setup Time │ 4-8 jam │ 30-60 menit │
└─────────────────────┴───────────────────┴───────────────────┘
SEO Verdict
Untuk content-focused websites (blog, landing page, portfolio):
Astro menang telak.
- Setup lebih cepat
- Score lebih tinggi by default
- Maintenance lebih mudah
- Client lebih happy
Untuk dynamic applications (SaaS, e-commerce dengan user accounts):
Laravel adalah pilihan karena Astro memang tidak designed untuk use case itu.
Bagian 6: Case Study — Blog untuk Client
Mari kita simulate projek nyata. Seorang client datang dengan brief:
Brief dari Client
Client: PT Maju Jaya (Company manufacturing)
Request: Company blog untuk content marketing
Requirements:
├── Blog dengan artikel tentang industri
├── SEO friendly (mau ranking di Google)
├── Client bisa update artikel sendiri
├── Responsive design
├── Fast loading
├── Budget: Rp 3-5 juta
├── Timeline: 2 minggu
└── Hosting budget: seminimal mungkin
Expected Output:
├── Homepage dengan featured articles
├── Blog listing page dengan pagination
├── Article detail page
├── Category pages
├── About & Contact page
└── Admin/CMS untuk manage content
Approach A: Laravel
Project Structure:
blog-majujaya-laravel/
├── app/
│ ├── Http/
│ │ ├── Controllers/
│ │ │ ├── BlogController.php
│ │ │ ├── PageController.php
│ │ │ └── Admin/
│ │ │ ├── PostController.php
│ │ │ ├── CategoryController.php
│ │ │ └── DashboardController.php
│ │ └── Middleware/
│ │ └── AdminMiddleware.php
│ ├── Models/
│ │ ├── Post.php
│ │ ├── Category.php
│ │ └── User.php
│ └── Services/
│ └── ImageService.php
├── database/
│ ├── migrations/
│ │ ├── create_posts_table.php
│ │ └── create_categories_table.php
│ └── seeders/
├── resources/
│ └── views/
│ ├── layouts/
│ │ └── app.blade.php
│ ├── blog/
│ │ ├── index.blade.php
│ │ └── show.blade.php
│ ├── pages/
│ │ ├── home.blade.php
│ │ ├── about.blade.php
│ │ └── contact.blade.php
│ └── admin/
│ ├── dashboard.blade.php
│ └── posts/
│ ├── index.blade.php
│ ├── create.blade.php
│ └── edit.blade.php
├── routes/
│ └── web.php
└── public/
Key Implementation:
// database/migrations/create_posts_table.php
Schema::create('posts', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->string('title');
$table->string('slug')->unique();
$table->text('excerpt');
$table->longText('content');
$table->string('featured_image')->nullable();
$table->string('meta_title')->nullable();
$table->string('meta_description')->nullable();
$table->boolean('is_published')->default(false);
$table->timestamp('published_at')->nullable();
$table->timestamps();
$table->index(['is_published', 'published_at']);
});
// app/Http/Controllers/Admin/PostController.php
class PostController extends Controller
{
public function index()
{
$posts = Post::with(['category', 'author'])
->latest()
->paginate(15);
return view('admin.posts.index', compact('posts'));
}
public function create()
{
$categories = Category::orderBy('name')->get();
return view('admin.posts.create', compact('categories'));
}
public function store(Request $request)
{
$validated = $request->validate([
'title' => 'required|max:255',
'category_id' => 'required|exists:categories,id',
'content' => 'required',
'featured_image' => 'nullable|image|max:2048',
'is_published' => 'boolean',
]);
$validated['slug'] = Str::slug($validated['title']);
$validated['user_id'] = auth()->id();
$validated['excerpt'] = Str::limit(strip_tags($validated['content']), 160);
if ($request->hasFile('featured_image')) {
$validated['featured_image'] = app(ImageService::class)
->optimizeAndStore($request->file('featured_image'));
}
if ($request->is_published) {
$validated['published_at'] = now();
}
Post::create($validated);
// Clear cache
Cache::forget('blog.posts');
Cache::forget('home.featured');
return redirect()
->route('admin.posts.index')
->with('success', 'Artikel berhasil dibuat!');
}
public function edit(Post $post)
{
$categories = Category::orderBy('name')->get();
return view('admin.posts.edit', compact('post', 'categories'));
}
public function update(Request $request, Post $post)
{
$validated = $request->validate([
'title' => 'required|max:255',
'category_id' => 'required|exists:categories,id',
'content' => 'required',
'featured_image' => 'nullable|image|max:2048',
'is_published' => 'boolean',
]);
$validated['excerpt'] = Str::limit(strip_tags($validated['content']), 160);
if ($request->hasFile('featured_image')) {
// Delete old image
if ($post->featured_image) {
Storage::delete($post->featured_image);
}
$validated['featured_image'] = app(ImageService::class)
->optimizeAndStore($request->file('featured_image'));
}
// Handle publish status change
if ($request->is_published && !$post->is_published) {
$validated['published_at'] = now();
}
$post->update($validated);
// Clear cache
Cache::forget('blog.posts');
Cache::forget("post.{$post->slug}");
return redirect()
->route('admin.posts.index')
->with('success', 'Artikel berhasil diupdate!');
}
public function destroy(Post $post)
{
if ($post->featured_image) {
Storage::delete($post->featured_image);
}
$post->delete();
Cache::forget('blog.posts');
return redirect()
->route('admin.posts.index')
->with('success', 'Artikel berhasil dihapus!');
}
}
Development Timeline Laravel:
Day 1-2:
├── Setup project, database, migrations
├── Create models dengan relationships
└── Setup authentication
Day 3-4:
├── Admin dashboard layout
├── CRUD untuk posts
└── CRUD untuk categories
Day 5-6:
├── Frontend blog pages
├── SEO setup (meta tags, sitemap)
└── Image optimization
Day 7:
├── Testing & bug fixes
├── Performance optimization
└── Deployment
Total: 5-7 hari kerja
Hosting Setup:
DigitalOcean Droplet: $6/month
├── 1GB RAM, 1 vCPU
├── Ubuntu 24.04
├── PHP 8.3, MySQL 8
└── Nginx, SSL via Certbot
Atau Railway/Render: $5-10/month
Hasil:
- Lighthouse Score: 78-85
- Fully custom admin panel
- Complete control
- Monthly hosting: ~Rp 100K
Approach B: Astro + Headless CMS
Project Structure:
blog-majujaya-astro/
├── src/
│ ├── components/
│ │ ├── Header.astro
│ │ ├── Footer.astro
│ │ ├── PostCard.astro
│ │ ├── Pagination.astro
│ │ └── SEO.astro
│ ├── layouts/
│ │ └── Layout.astro
│ ├── lib/
│ │ └── contentful.ts
│ ├── pages/
│ │ ├── index.astro
│ │ ├── about.astro
│ │ ├── contact.astro
│ │ ├── blog/
│ │ │ ├── index.astro
│ │ │ └── [...slug].astro
│ │ └── category/
│ │ └── [slug].astro
│ └── styles/
│ └── global.css
├── astro.config.mjs
├── package.json
└── tailwind.config.js
Headless CMS Options:
FREE Options:
├── Contentful (Free tier: 25K records)
├── Sanity (Free tier: 100K records)
├── Strapi (Self-hosted, free)
└── Decap CMS (Git-based, free)
Recommendation untuk client ini: Contentful
├── User-friendly untuk non-technical
├── Free tier cukup untuk company blog
├── Great image handling
└── Webhook untuk auto-rebuild
Contentful Integration:
// src/lib/contentful.ts
import contentful from 'contentful';
const client = contentful.createClient({
space: import.meta.env.CONTENTFUL_SPACE_ID,
accessToken: import.meta.env.CONTENTFUL_ACCESS_TOKEN,
});
export interface BlogPost {
title: string;
slug: string;
excerpt: string;
content: any; // Rich text
featuredImage: {
url: string;
title: string;
};
category: {
name: string;
slug: string;
};
author: string;
publishedAt: string;
}
export async function getPosts(): Promise<BlogPost[]> {
const response = await client.getEntries({
content_type: 'blogPost',
order: ['-fields.publishedAt'],
});
return response.items.map((item: any) => ({
title: item.fields.title,
slug: item.fields.slug,
excerpt: item.fields.excerpt,
content: item.fields.content,
featuredImage: {
url: item.fields.featuredImage?.fields?.file?.url,
title: item.fields.featuredImage?.fields?.title,
},
category: {
name: item.fields.category?.fields?.name,
slug: item.fields.category?.fields?.slug,
},
author: item.fields.author,
publishedAt: item.fields.publishedAt,
}));
}
export async function getPostBySlug(slug: string): Promise<BlogPost | null> {
const response = await client.getEntries({
content_type: 'blogPost',
'fields.slug': slug,
limit: 1,
});
if (response.items.length === 0) return null;
const item = response.items[0] as any;
return {
title: item.fields.title,
slug: item.fields.slug,
excerpt: item.fields.excerpt,
content: item.fields.content,
featuredImage: {
url: item.fields.featuredImage?.fields?.file?.url,
title: item.fields.featuredImage?.fields?.title,
},
category: {
name: item.fields.category?.fields?.name,
slug: item.fields.category?.fields?.slug,
},
author: item.fields.author,
publishedAt: item.fields.publishedAt,
};
}
export async function getCategories() {
const response = await client.getEntries({
content_type: 'category',
order: ['fields.name'],
});
return response.items.map((item: any) => ({
name: item.fields.name,
slug: item.fields.slug,
}));
}
Blog Listing Page:
---
// src/pages/blog/index.astro
import Layout from '../../layouts/Layout.astro';
import PostCard from '../../components/PostCard.astro';
import { getPosts } from '../../lib/contentful';
const posts = await getPosts();
---
<Layout
title="Blog | PT Maju Jaya"
description="Artikel dan insight seputar industri manufacturing"
>
<main class="container mx-auto px-4 py-12">
<h1 class="text-4xl font-bold mb-8">Blog</h1>
<div class="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{posts.map((post) => (
<PostCard post={post} />
))}
</div>
</main>
</Layout>
Post Detail Page:
---
// src/pages/blog/[...slug].astro
import Layout from '../../layouts/Layout.astro';
import { getPosts, getPostBySlug } from '../../lib/contentful';
import { documentToHtmlString } from '@contentful/rich-text-html-renderer';
export async function getStaticPaths() {
const posts = await getPosts();
return posts.map((post) => ({
params: { slug: post.slug },
}));
}
const { slug } = Astro.params;
const post = await getPostBySlug(slug);
if (!post) {
return Astro.redirect('/404');
}
const contentHtml = documentToHtmlString(post.content);
const publishedDate = new Date(post.publishedAt);
---
<Layout
title={`${post.title} | PT Maju Jaya`}
description={post.excerpt}
image={post.featuredImage.url}
article={{
publishedTime: publishedDate,
author: post.author,
section: post.category.name,
}}
>
<article class="container mx-auto px-4 py-12 max-w-3xl">
<header class="mb-8">
<div class="text-sm text-gray-500 mb-2">
<a href={`/category/${post.category.slug}`} class="hover:underline">
{post.category.name}
</a>
<span class="mx-2">•</span>
<time datetime={publishedDate.toISOString()}>
{publishedDate.toLocaleDateString('id-ID', {
year: 'numeric',
month: 'long',
day: 'numeric'
})}
</time>
</div>
<h1 class="text-4xl font-bold mb-4">{post.title}</h1>
<p class="text-gray-600">Oleh {post.author}</p>
</header>
{post.featuredImage.url && (
<img
src={post.featuredImage.url}
alt={post.featuredImage.title}
class="w-full rounded-lg mb-8"
loading="eager"
/>
)}
<div class="prose prose-lg max-w-none" set:html={contentHtml} />
</article>
</Layout>
Development Timeline Astro:
Day 1:
├── Setup Astro project
├── Setup Contentful (content types, sample data)
└── Configure Contentful integration
Day 2:
├── Create layouts & components
├── Build all pages
└── Style dengan Tailwind
Day 3:
├── SEO optimization (sudah minimal by default)
├── Setup Netlify deployment
├── Configure webhook untuk auto-rebuild
├── Testing & handover ke client
Total: 2-3 hari kerja
Hosting Setup:
Netlify (Free tier):
├── 100GB bandwidth/month
├── Auto SSL
├── CDN included
├── Webhook auto-deploy
└── Cost: $0
Contentful (Free tier):
├── 25,000 records
├── 2 users
├── Asset storage
└── Cost: $0
Total: $0/month
Hasil:
- Lighthouse Score: 98-100
- Client punya user-friendly CMS
- Zero hosting cost
- Auto-deploy on content change
Comparison untuk Case Blog Ini
┌─────────────────────┬───────────────────┬───────────────────┐
│ Aspek │ Laravel │ Astro + CMS │
├─────────────────────┼───────────────────┼───────────────────┤
│ Development Time │ 5-7 hari │ 2-3 hari │
│ Lighthouse Score │ 78-85 │ 98-100 │
│ Monthly Hosting │ Rp 80-150K │ Rp 0 │
│ Annual Hosting │ Rp 1-1.8 juta │ Rp 0 │
│ SEO Setup Time │ 4-8 jam │ 30 menit │
│ Client CMS │ Custom admin │ Contentful UI │
│ Learning Curve │ Client perlu train│ Intuitive │
│ Maintenance │ Server updates │ Almost zero │
│ Scalability │ Need server scale │ CDN handles it │
└─────────────────────┴───────────────────┴───────────────────┘
Verdict untuk Case Blog
Winner: Astro + Headless CMS
Alasan:
- Lebih cepat develop — Hemat 3-4 hari = bisa ambil projek lain
- SEO lebih bagus — Score 100 vs 85, client lebih happy
- Hosting gratis — Bisa jadi profit tambahan atau kompetitif pricing
- Maintenance minimal — Tidak perlu worry server down
- Client experience better — Contentful lebih user-friendly dari custom admin
Kapan Pilih Laravel untuk Blog?
Ada beberapa situasi dimana Laravel tetap pilihan yang tepat untuk blog:
- Blog butuh user registration — Komentar dengan login, member area
- Blog terintegrasi dengan app lain — Bagian dari SaaS atau e-commerce
- Multi-author dengan complex roles — Editor, reviewer, publisher
- Client sudah punya Laravel ecosystem — Mau integrasi dengan sistem existing
- Butuh custom workflow — Draft → Review → Approval → Publish
Kalau requirement-nya pure content blog untuk SEO dan marketing? Astro wins.
Bagian 7: Case Study — SaaS Sederhana (Invoice App)
Sekarang mari kita lihat case yang berbeda — projek yang butuh backend functionality.
Brief dari Client
Client: Freelance Designer yang mau scale
Request: Aplikasi untuk manage invoices
Requirements:
├── User bisa register dan login
├── CRUD invoices (create, read, update, delete)
├── Invoice items dengan kalkulasi otomatis
├── Generate PDF invoice
├── Dashboard dengan statistik
│ ├── Total invoices bulan ini
│ ├── Total pending payment
│ ├── Total sudah dibayar
│ └── Grafik sederhana
├── Manage clients
├── Status tracking (draft, sent, paid, overdue)
├── Budget: Rp 15-25 juta
├── Timeline: 1-2 bulan
└── Expectation: Bisa di-scale untuk dijual ke designer lain (SaaS)
Kenapa Astro TIDAK Cocok untuk Ini
Mari kita analisis requirements satu per satu:
User Authentication:
├── Astro: ✗ Tidak punya built-in auth
├── Workaround: Auth0, Clerk, Supabase Auth
└── Verdict: Complexity naik, cost naik
Database untuk Invoices:
├── Astro: ✗ Tidak punya database
├── Workaround: Supabase, Firebase, PlanetScale
└── Verdict: External dependency, learning curve
CRUD Operations:
├── Astro: ✗ Static by nature
├── Workaround: API routes (limited) atau external API
└── Verdict: Awkward, not designed for this
PDF Generation:
├── Astro: ✗ Tidak ada server-side processing
├── Workaround: External PDF API (cost per document)
└── Verdict: Additional cost, complexity
Real-time Dashboard:
├── Astro: ✗ Static pages
├── Workaround: Client-side fetching + external DB
└── Verdict: Not efficient, bad UX
Kalau maksa pakai Astro:
Tech Stack yang Dibutuhkan:
├── Astro (frontend)
├── Supabase (auth + database): $25/month
├── PDF API (e.g., PDFShift): $9-29/month
├── Vercel (untuk API routes): $20/month
└── Total: $54-74/month
Plus:
├── Complexity: Very High
├── Development Time: Lebih lama (belajar multiple services)
├── Debugging: Nightmare (multiple systems)
└── Maintenance: Multiple bills, multiple dashboards
Dengan Laravel:
Tech Stack:
├── Laravel (everything)
├── DigitalOcean Droplet: $12/month
└── Total: $12/month
Plus:
├── Complexity: Manageable (satu ecosystem)
├── Development Time: Standard
├── Debugging: Satu codebase
└── Maintenance: Satu server, satu bill
Verdict: Laravel adalah satu-satunya pilihan yang masuk akal.
Laravel Implementation
Database Schema:
// database/migrations/create_clients_table.php
Schema::create('clients', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->string('email');
$table->string('phone')->nullable();
$table->string('company')->nullable();
$table->text('address')->nullable();
$table->timestamps();
$table->index('user_id');
});
// database/migrations/create_invoices_table.php
Schema::create('invoices', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->foreignId('client_id')->constrained()->onDelete('cascade');
$table->string('invoice_number')->unique();
$table->date('issue_date');
$table->date('due_date');
$table->enum('status', ['draft', 'sent', 'paid', 'overdue'])->default('draft');
$table->decimal('subtotal', 12, 2)->default(0);
$table->decimal('tax_rate', 5, 2)->default(11); // PPN 11%
$table->decimal('tax_amount', 12, 2)->default(0);
$table->decimal('total', 12, 2)->default(0);
$table->text('notes')->nullable();
$table->timestamp('paid_at')->nullable();
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index(['user_id', 'created_at']);
});
// database/migrations/create_invoice_items_table.php
Schema::create('invoice_items', function (Blueprint $table) {
$table->id();
$table->foreignId('invoice_id')->constrained()->onDelete('cascade');
$table->string('description');
$table->integer('quantity')->default(1);
$table->decimal('unit_price', 12, 2);
$table->decimal('amount', 12, 2);
$table->timestamps();
});
Models dengan Business Logic:
// app/Models/Invoice.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
use Illuminate\\Database\\Eloquent\\Relations\\BelongsTo;
use Illuminate\\Database\\Eloquent\\Relations\\HasMany;
class Invoice extends Model
{
protected $fillable = [
'user_id',
'client_id',
'invoice_number',
'issue_date',
'due_date',
'status',
'subtotal',
'tax_rate',
'tax_amount',
'total',
'notes',
'paid_at',
];
protected $casts = [
'issue_date' => 'date',
'due_date' => 'date',
'paid_at' => 'datetime',
'subtotal' => 'decimal:2',
'tax_rate' => 'decimal:2',
'tax_amount' => 'decimal:2',
'total' => 'decimal:2',
];
// Relationships
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function items(): HasMany
{
return $this->hasMany(InvoiceItem::class);
}
// Business Logic
public function calculateTotals(): void
{
$this->subtotal = $this->items->sum('amount');
$this->tax_amount = $this->subtotal * ($this->tax_rate / 100);
$this->total = $this->subtotal + $this->tax_amount;
$this->save();
}
public function markAsSent(): void
{
$this->update(['status' => 'sent']);
}
public function markAsPaid(): void
{
$this->update([
'status' => 'paid',
'paid_at' => now(),
]);
}
public function checkOverdue(): void
{
if ($this->status === 'sent' && $this->due_date->isPast()) {
$this->update(['status' => 'overdue']);
}
}
// Generate Invoice Number
public static function generateNumber(): string
{
$year = date('Y');
$month = date('m');
$count = self::whereYear('created_at', $year)
->whereMonth('created_at', $month)
->count() + 1;
return sprintf('INV-%s%s-%04d', $year, $month, $count);
}
// Scopes
public function scopeForUser($query, $userId)
{
return $query->where('user_id', $userId);
}
public function scopeByStatus($query, $status)
{
return $query->where('status', $status);
}
public function scopeThisMonth($query)
{
return $query->whereMonth('created_at', now()->month)
->whereYear('created_at', now()->year);
}
public function scopeOverdue($query)
{
return $query->where('status', 'sent')
->where('due_date', '<', now());
}
}
// app/Models/InvoiceItem.php
namespace App\\Models;
use Illuminate\\Database\\Eloquent\\Model;
class InvoiceItem extends Model
{
protected $fillable = [
'invoice_id',
'description',
'quantity',
'unit_price',
'amount',
];
protected $casts = [
'unit_price' => 'decimal:2',
'amount' => 'decimal:2',
];
protected static function booted()
{
// Auto-calculate amount when saving
static::saving(function ($item) {
$item->amount = $item->quantity * $item->unit_price;
});
// Recalculate invoice totals after item changes
static::saved(function ($item) {
$item->invoice->calculateTotals();
});
static::deleted(function ($item) {
$item->invoice->calculateTotals();
});
}
public function invoice()
{
return $this->belongsTo(Invoice::class);
}
}
Controller dengan Proper Authorization:
// app/Http/Controllers/InvoiceController.php
namespace App\\Http\\Controllers;
use App\\Models\\Invoice;
use App\\Models\\Client;
use App\\Services\\InvoicePdfService;
use Illuminate\\Http\\Request;
use Illuminate\\Support\\Facades\\DB;
class InvoiceController extends Controller
{
public function __construct()
{
$this->authorizeResource(Invoice::class, 'invoice');
}
public function index(Request $request)
{
$query = Invoice::forUser(auth()->id())
->with('client')
->latest();
// Filter by status
if ($request->filled('status')) {
$query->byStatus($request->status);
}
// Search
if ($request->filled('search')) {
$search = $request->search;
$query->where(function ($q) use ($search) {
$q->where('invoice_number', 'like', "%{$search}%")
->orWhereHas('client', function ($q) use ($search) {
$q->where('name', 'like', "%{$search}%");
});
});
}
$invoices = $query->paginate(15)->withQueryString();
return view('invoices.index', compact('invoices'));
}
public function create()
{
$clients = Client::where('user_id', auth()->id())
->orderBy('name')
->get();
return view('invoices.create', compact('clients'));
}
public function store(Request $request)
{
$validated = $request->validate([
'client_id' => 'required|exists:clients,id',
'issue_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:issue_date',
'tax_rate' => 'required|numeric|min:0|max:100',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
// Verify client belongs to user
$client = Client::where('id', $validated['client_id'])
->where('user_id', auth()->id())
->firstOrFail();
$invoice = DB::transaction(function () use ($validated) {
$invoice = Invoice::create([
'user_id' => auth()->id(),
'client_id' => $validated['client_id'],
'invoice_number' => Invoice::generateNumber(),
'issue_date' => $validated['issue_date'],
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'],
'notes' => $validated['notes'] ?? null,
'status' => 'draft',
]);
foreach ($validated['items'] as $item) {
$invoice->items()->create([
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
]);
}
// Totals calculated automatically via model events
return $invoice;
});
return redirect()
->route('invoices.show', $invoice)
->with('success', 'Invoice berhasil dibuat!');
}
public function show(Invoice $invoice)
{
$invoice->load(['client', 'items']);
return view('invoices.show', compact('invoice'));
}
public function edit(Invoice $invoice)
{
$invoice->load('items');
$clients = Client::where('user_id', auth()->id())
->orderBy('name')
->get();
return view('invoices.edit', compact('invoice', 'clients'));
}
public function update(Request $request, Invoice $invoice)
{
// Prevent editing paid invoices
if ($invoice->status === 'paid') {
return back()->with('error', 'Invoice yang sudah dibayar tidak bisa diedit.');
}
$validated = $request->validate([
'client_id' => 'required|exists:clients,id',
'issue_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:issue_date',
'tax_rate' => 'required|numeric|min:0|max:100',
'notes' => 'nullable|string|max:1000',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
DB::transaction(function () use ($invoice, $validated) {
$invoice->update([
'client_id' => $validated['client_id'],
'issue_date' => $validated['issue_date'],
'due_date' => $validated['due_date'],
'tax_rate' => $validated['tax_rate'],
'notes' => $validated['notes'] ?? null,
]);
// Replace all items
$invoice->items()->delete();
foreach ($validated['items'] as $item) {
$invoice->items()->create([
'description' => $item['description'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
]);
}
});
return redirect()
->route('invoices.show', $invoice)
->with('success', 'Invoice berhasil diupdate!');
}
public function destroy(Invoice $invoice)
{
if ($invoice->status === 'paid') {
return back()->with('error', 'Invoice yang sudah dibayar tidak bisa dihapus.');
}
$invoice->delete();
return redirect()
->route('invoices.index')
->with('success', 'Invoice berhasil dihapus!');
}
// Additional Actions
public function send(Invoice $invoice)
{
$this->authorize('update', $invoice);
if ($invoice->status !== 'draft') {
return back()->with('error', 'Hanya invoice draft yang bisa dikirim.');
}
$invoice->markAsSent();
// TODO: Send email to client
return back()->with('success', 'Invoice berhasil dikirim!');
}
public function markPaid(Invoice $invoice)
{
$this->authorize('update', $invoice);
if (!in_array($invoice->status, ['sent', 'overdue'])) {
return back()->with('error', 'Status invoice tidak valid.');
}
$invoice->markAsPaid();
return back()->with('success', 'Invoice ditandai sudah dibayar!');
}
public function downloadPdf(Invoice $invoice, InvoicePdfService $pdfService)
{
$this->authorize('view', $invoice);
$pdf = $pdfService->generate($invoice);
return $pdf->download("invoice-{$invoice->invoice_number}.pdf");
}
}
Dashboard dengan Statistics:
// app/Http/Controllers/DashboardController.php
namespace App\\Http\\Controllers;
use App\\Models\\Invoice;
use Illuminate\\Support\\Facades\\DB;
class DashboardController extends Controller
{
public function index()
{
$userId = auth()->id();
// Statistics
$stats = [
'total_invoices' => Invoice::forUser($userId)->count(),
'invoices_this_month' => Invoice::forUser($userId)
->thisMonth()
->count(),
'pending_amount' => Invoice::forUser($userId)
->whereIn('status', ['sent', 'overdue'])
->sum('total'),
'paid_this_month' => Invoice::forUser($userId)
->byStatus('paid')
->thisMonth()
->sum('total'),
'overdue_count' => Invoice::forUser($userId)
->byStatus('overdue')
->count(),
];
// Monthly revenue (last 6 months)
$monthlyRevenue = Invoice::forUser($userId)
->byStatus('paid')
->where('paid_at', '>=', now()->subMonths(6))
->select(
DB::raw('YEAR(paid_at) as year'),
DB::raw('MONTH(paid_at) as month'),
DB::raw('SUM(total) as total')
)
->groupBy('year', 'month')
->orderBy('year')
->orderBy('month')
->get();
// Recent invoices
$recentInvoices = Invoice::forUser($userId)
->with('client')
->latest()
->limit(5)
->get();
// Overdue invoices
$overdueInvoices = Invoice::forUser($userId)
->with('client')
->byStatus('overdue')
->orderBy('due_date')
->limit(5)
->get();
return view('dashboard', compact(
'stats',
'monthlyRevenue',
'recentInvoices',
'overdueInvoices'
));
}
}
PDF Generation Service:
// app/Services/InvoicePdfService.php
namespace App\\Services;
use App\\Models\\Invoice;
use Barryvdh\\DomPDF\\Facade\\Pdf;
class InvoicePdfService
{
public function generate(Invoice $invoice)
{
$invoice->load(['client', 'items', 'user']);
$pdf = Pdf::loadView('invoices.pdf', [
'invoice' => $invoice,
]);
$pdf->setPaper('a4');
return $pdf;
}
}
Best Practice vs Bad Practice
Authorization:
// ❌ BAD: No authorization check
public function show($id)
{
$invoice = Invoice::find($id);
return view('invoices.show', compact('invoice'));
// User A bisa lihat invoice User B!
}
// ✅ GOOD: Proper authorization
public function show(Invoice $invoice)
{
$this->authorize('view', $invoice);
return view('invoices.show', compact('invoice'));
}
// Policy
class InvoicePolicy
{
public function view(User $user, Invoice $invoice)
{
return $user->id === $invoice->user_id;
}
}
N+1 Query Problem:
// ❌ BAD: N+1 queries
public function index()
{
$invoices = Invoice::all();
return view('invoices.index', compact('invoices'));
}
// Di view: {{ $invoice->client->name }} → N+1!
// ✅ GOOD: Eager loading
public function index()
{
$invoices = Invoice::with('client')->paginate(15);
return view('invoices.index', compact('invoices'));
}
Business Logic Placement:
// ❌ BAD: Business logic di controller
public function store(Request $request)
{
$invoice = Invoice::create($request->all());
$subtotal = 0;
foreach ($request->items as $item) {
$subtotal += $item['quantity'] * $item['unit_price'];
}
$invoice->subtotal = $subtotal;
$invoice->tax_amount = $subtotal * 0.11;
$invoice->total = $subtotal + $invoice->tax_amount;
$invoice->save();
}
// ✅ GOOD: Business logic di Model
public function store(Request $request)
{
$invoice = Invoice::create([...]);
foreach ($validated['items'] as $item) {
$invoice->items()->create($item);
}
// Totals calculated via Model events
}
Input Validation:
// ❌ BAD: No validation
public function store(Request $request)
{
Invoice::create($request->all());
// Mass assignment vulnerability!
// No type checking!
}
// ✅ GOOD: Proper validation
public function store(Request $request)
{
$validated = $request->validate([
'client_id' => 'required|exists:clients,id',
'issue_date' => 'required|date',
'due_date' => 'required|date|after_or_equal:issue_date',
'items' => 'required|array|min:1',
'items.*.description' => 'required|string|max:255',
'items.*.quantity' => 'required|integer|min:1',
'items.*.unit_price' => 'required|numeric|min:0',
]);
Invoice::create($validated);
}
SaaS Verdict
Untuk aplikasi dengan requirements seperti Invoice App ini:
Laravel adalah pilihan yang tepat dan SATU-SATUNYA yang masuk akal.
Kenapa?
- Built-in authentication — Tinggal pakai, aman, tested
- Eloquent ORM — Database operations jadi mudah
- PDF generation — Packages tersedia dan battle-tested
- Single codebase — Easier debugging dan maintenance
- Scalable — Bisa handle growth ke ribuan users
- Cost effective — $12/month vs $50+/month kalau pakai stack terpisah
Bagian 8: Decision Framework dan Closing
Decision Matrix Final
┌─────────────────────────────────────────────────────────────────┐
│ PILIH ASTRO JIKA: │
├─────────────────────────────────────────────────────────────────┤
│ ✓ Projek: Blog, landing page, portfolio, company profile │
│ ✓ Content-focused (artikel, gambar, video) │
│ ✓ SEO adalah prioritas utama │
│ ✓ Budget hosting terbatas atau mau gratis │
│ ✓ Tidak butuh user authentication │
│ ✓ Tidak butuh database untuk user data │
│ ✓ Client mau website super cepat (Lighthouse 100) │
│ ✓ Timeline ketat (butuh cepat selesai) │
│ ✓ Maintenance minimal preferred │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ PILIH LARAVEL JIKA: │
├─────────────────────────────────────────────────────────────────┤
│ ✓ Projek: SaaS, e-commerce, membership, booking system │
│ ✓ Butuh user registration dan login │
│ ✓ Butuh database untuk menyimpan data user │
│ ✓ Butuh payment integration │
│ ✓ Butuh admin dashboard yang kompleks │
│ ✓ Butuh API untuk mobile app atau integrasi │
│ ✓ Complex business logic │
│ ✓ Real-time features (notifications, chat) │
│ ✓ Long-term project dengan banyak fitur │
└─────────────────────────────────────────────────────────────────┘
Untuk Freelancer Pemula — Urutan Belajar yang Recommended
Berdasarkan pengalaman ribuan students BuildWithAngga dan realita market Indonesia:
STEP 1: Mulai dengan Astro (1-2 minggu)
Kenapa Astro dulu?
├── Learning curve rendah — bisa produktif dalam days
├── Langsung bisa dapat projek landing page
├── Build portfolio sendiri dengan Astro
├── Mulai dapat income sambil belajar Laravel
├── Paham fundamental web (HTML, CSS, JS)
└── Confidence boost dari projek selesai
STEP 2: Belajar Laravel (1-2 bulan)
Setelah dapat income dari Astro:
├── Ada budget untuk belajar lebih serius
├── Ada motivasi (sudah taste money from coding)
├── Bisa handle projek yang lebih kompleks
├── Income per projek lebih tinggi
└── Bisa combine Astro + Laravel (premium pricing)
Income Potential — Realistic Numbers
Berdasarkan data dari students BuildWithAngga yang sudah freelance:
Astro Projects:
Landing page sederhana: Rp 1 - 3 juta
Blog tanpa CMS: Rp 2 - 4 juta
Blog dengan Headless CMS: Rp 3 - 6 juta
Company profile (5-10 pages): Rp 3 - 7 juta
Portfolio website: Rp 1 - 3 juta
Documentation site: Rp 3 - 8 juta
Development time: 2-7 hari
Volume potential: 3-5 projek/bulan
Monthly potential: Rp 5 - 20 juta
Laravel Projects:
Blog dengan custom admin: Rp 5 - 12 juta
Membership site: Rp 10 - 20 juta
Simple e-commerce: Rp 15 - 30 juta
Booking system: Rp 12 - 25 juta
SaaS MVP: Rp 20 - 75 juta
Custom CRM/ERP: Rp 30 - 100+ juta
Development time: 1-8 minggu
Volume potential: 1-2 projek/bulan
Monthly potential: Rp 10 - 40 juta
Combined (Astro + Laravel):
Quick wins dari Astro + big projects dari Laravel
Monthly potential: Rp 15 - 50+ juta
Bonus: Hybrid projects (Astro frontend + Laravel API)
├── Premium pricing karena "best of both worlds"
├── Client dapat SEO perfect + full functionality
└── Rate: 20-30% lebih tinggi dari single-stack
Hybrid Architecture — Best of Both Worlds
Untuk projek premium, kamu bisa combine keduanya:
┌─────────────────────────────────────────────────────────────────┐
│ HYBRID ARCHITECTURE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ ASTRO │ API │ LARAVEL │ │
│ │ Frontend │ ◄──────► │ Backend │ │
│ │ │ JSON │ │ │
│ │ • Homepage │ │ • Auth API │ │
│ │ • Blog │ │ • Database │ │
│ │ • Landing pages │ │ • Business logic│ │
│ │ • Marketing │ │ • Admin panel │ │
│ │ │ │ • Payment │ │
│ └────────┬────────┘ └────────┬────────┘ │
│ │ │ │
│ ▼ ▼ │
│ Netlify/Vercel DigitalOcean │
│ (FREE) ($12/mo) │
│ │
├─────────────────────────────────────────────────────────────────┤
│ BENEFITS: │
│ • Marketing pages: Lighthouse 100, SEO perfect │
│ • App functionality: Full Laravel power │
│ • Cost efficient: Static hosting free │
│ • Scalable: Each part scales independently │
│ • Premium pricing: "Modern architecture" selling point │
└─────────────────────────────────────────────────────────────────┘
Final Recommendation
Untuk Absolute Beginner (baru mulai coding):
→ START dengan ASTRO
Alasan:
├── Lebih cepat dapat projek pertama
├── Confidence boost dari projek selesai
├── Income mulai mengalir dalam minggu
├── Fondasi untuk belajar framework lain
└── Portfolio kamu sendiri bisa pakai Astro
Untuk yang Sudah Paham Basic Web (HTML, CSS, JS):
→ Langsung LARAVEL
Alasan:
├── Skip Astro kalau sudah paham fundamentals
├── Projek lebih valuable dan challenging
├── Skill lebih marketable (job market besar)
└── Bisa add Astro later untuk diversifikasi
Untuk Maximum Earning Potential:
→ BELAJAR KEDUA-DUANYA
Strategy:
├── Astro untuk quick wins dan cashflow
├── Laravel untuk big projects dan growth
├── Hybrid untuk premium clients
└── Positioning: "Full-stack modern developer"
Penutup
Framework adalah tools, bukan identitas.
Saya sering lihat developer yang terlalu loyal ke satu framework sampai menolak belajar yang lain. "Saya Laravel developer, gak mau belajar JavaScript framework." Atau sebaliknya.
Freelancer yang sukses bukan yang paling jago satu framework. Tapi yang bisa solve masalah client dengan tools yang tepat.
Client gak peduli kamu pakai Laravel atau Astro. Client peduli:
- Website-nya cepat gak?
- Bisa masuk Google gak?
- Sesuai budget gak?
- Selesai tepat waktu gak?
Kalau kamu bisa deliver semua itu dengan tools yang tepat, client happy, kamu dapat uang, everybody wins.
Yang paling penting: MULAI.
Pilih satu, selesaikan 3 projek, lalu expand. Jangan terjebak tutorial hell — nonton tutorial 100 jam tapi gak pernah bikin projek nyata.
Projek pertama gak harus sempurna. Projek pertama harus SELESAI.
Setelah selesai, iterate. Projek kedua akan lebih baik. Projek ketiga lebih baik lagi. Dan seterusnya.
Good luck di perjalanan freelance kamu! 🚀
Rekomendasi: Belajar di BuildWithAngga
Kalau kamu serius mau jadi freelancer web developer yang bisa kerja remote — baik untuk client lokal maupun internasional — saya recommend untuk belajar terstruktur di BuildWithAngga.
Kenapa BuildWithAngga?
Di BuildWithAngga, kamu gak cuma belajar coding. Kamu belajar skill yang dibutuhkan untuk kerja remote dan freelance:
APA YANG KAMU DAPAT:
────────────────────────────────────────────────────────
📚 KELAS LENGKAP & TERSTRUKTUR
├── Laravel dari zero sampai advanced
├── Astro JS untuk modern static sites
├── React, Vue, Next.js, dan framework modern lainnya
├── UI/UX Design dengan Figma
├── Database design dan optimization
└── Semua dengan studi kasus projek nyata
💼 PORTFOLIO-READY PROJECTS
├── Setiap kelas ada final project
├── Projek yang bisa langsung masuk portfolio
├── Studi kasus real-world, bukan todo app
└── Code review dan feedback
🎯 CAREER-FOCUSED CONTENT
├── Cara dapat client freelance pertama
├── Pricing strategy untuk freelancer
├── Proposal dan contract template
├── Komunikasi dengan client internasional
└── Tips lolos interview remote job
👨🏫 MENTOR SUPPORT
├── Tanya jawab langsung dengan mentor
├── Code review untuk projectmu
├── Career guidance
└── Community Discord yang aktif
📜 SERTIFIKAT
├── Sertifikat setiap selesai kelas
├── Bisa ditambahkan ke LinkedIn
├── Proof of competency untuk client
└── Nilai tambah saat apply job
🔄 LIFETIME ACCESS
├── Akses selamanya ke materi
├── Update materi mengikuti perkembangan teknologi
├── Gak perlu bayar lagi untuk update
└── Belajar sesuai pace kamu sendiri
💰 HARGA TERJANGKAU
├── Lebih murah dari bootcamp
├── Bisa cicil
├── ROI tinggi (skill langsung menghasilkan)
└── Diskon regular untuk member
Kelas yang Recommended untuk Freelancer:
FOUNDATION (Mulai dari sini):
├── HTML CSS JavaScript Fundamentals
├── Git & GitHub untuk Collaboration
└── Responsive Web Design
FRONTEND PATH:
├── React JS Complete Guide
├── Astro JS: Modern Static Sites
├── Next.js Full-Stack Development
└── Tailwind CSS Mastery
BACKEND PATH:
├── Laravel 11 Complete Guide
├── Laravel API Development
├── Laravel Livewire
└── Database Design & Optimization
FULL-STACK PROJECTS:
├── Build SaaS dengan Laravel
├── E-commerce dengan Laravel + Vue
├── Portfolio dengan Astro
└── Company Profile Modern
CAREER & FREELANCE:
├── Freelance Starter Kit
├── Remote Job Preparation
└── Building Your Personal Brand
Success Stories:
Ribuan students BuildWithAngga sudah berhasil:
- Dapat pekerjaan remote pertama
- Memulai karir freelance
- Naik gaji setelah upgrade skill
- Pindah karir ke tech dari background non-tech
Mulai Sekarang:
🌐 Website: buildwithangga.com
📱 Download App: Available di Play Store & App Store
💬 Community: Discord BuildWithAngga
📧 Questions: [email protected]
Investasi di skill adalah investasi terbaik. Ilmu gak bisa dicuri, gak bisa hilang, dan nilainya terus naik seiring pengalaman.
See you di kelas!
— Angga Risky Setiawan Founder, BuildWithAngga