Skip to content

Getting Started

This guide will walk you through setting up a new project using the Laravel Starter Kit and provide recommendations for extending it to fit your application-specific needs.

Follow the installation steps from the README:

Terminal window
git clone git@github.com:mindtwo/laravel-starter-kit.git your-project-name
cd your-project-name
just --list # Check out available tasks
just setup

Your application should now be running at https://your-project-name.test.

After the initial setup, customize these configurations for your specific project:

Important: The secure-headers configuration must be customized for every project. For the most part, this will be updated and extended as new requirements or dependencies come in.

Edit config/secure-headers.php:

// Adjust CSP directives based on your external resources
'content-security-policy' => [
'script-src' => [
'self',
// Add your CDN domains, analytics providers, etc.
// 'https://cdn.example.com',
// 'https://www.googletagmanager.com',
],
'style-src' => [
'self',
'unsafe-inline', // Required for Tailwind (consider removing in production)
// Add your font/style CDNs
],
'img-src' => [
'self',
'data:',
// Add your image CDNs
],
// ... customize other directives
],

This starter kit encourages clean, maintainable code through separation of concerns and explicit design patterns.

  1. Keep Controllers Slim: Controllers should only handle HTTP concerns (pass through request data, handle responses). Validation belongs in form request classes.
  2. Keep Models Slim: Models should define relationships, casts, and simple scopes
  3. Extract Business Logic: Application logic belongs in service classes
  4. Avoid Magic: Prefer dependency injection over facades; be explicit
  5. Follow Laravel Conventions: Use events, listeners, jobs, mails, policies, observers

Services contain your application’s business logic and should be the primary place where functionality is implemented.

Example Structure:

namespace App\Services;
use App\Models\Order;
use App\Models\Product;
use App\Repositories\OrderRepository;
use Illuminate\Support\Facades\DB;
class OrderService
{
public function __construct(
private OrderRepository $orders,
private PaymentService $payments,
private NotificationService $notifications,
) {}
public function createOrder(array $items, int $userId): Order
{
return DB::transaction(function () use ($items, $userId) {
$order = Order::query()->create([
'user_id' => $userId,
'status' => 'pending',
]);
// Attach items
foreach ($items as $item) {
$order->items()->create($item);
}
// Calculate total
$order->update(['total' => $order->items->sum('subtotal')]);
if ($this->orders->hasQualifiedForRaffle($userId)) {
$this->createRaffleParticipation($userId);
}
$this->payments->process($order);
$this->notifications->sendOrderConfirmation($order);
return $order;
});
}
}

Controller Usage:

class OrderController extends Controller
{
public function __construct(
private OrderService $orders,
) {}
public function store(CreateOrderRequest $request): RedirectResponse
{
$order = $this->orders->createOrder(
items: $request->validated('items'),
userId: $request->user()->id
);
flash()->success('Order created successfully');
return redirect()->route('orders.show', $order);
}
}

Don’t create repositories for every model. Eloquent already provides this abstraction.

Do extract complex or reusable queries into repository classes:

namespace App\Repositories;
use App\Models\Product;
use Chiiya\Common\Repositories\AbstractRepository;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* @extends AbstractRepository<Product>
*/
class ProductRepository extends AbstractRepository
{
protected string $model = Product::class;
/**
* Get featured products with their related data
*/
public function getFeaturedProducts(int $limit = 10): Collection
{
return $this->newQuery()
->where('is_featured', '=', true)
->where('status', '=', 'active')
->whereHas('inventory', fn($q) => $q->where('stock', '>', 0))
->with(['category', 'images', 'reviews' => fn (HasMany $q) =>
$q->where('rating', '>=', 4)->latest()->limit(5)
])
->withAvg('reviews', 'rating')
->withCount('orders')
->orderByDesc('featured_at')
->limit($limit)
->get();
}
}

For simple queries, use Model::query() directly:

// Controller or Service - no repository needed
$activeProducts = Product::query()->where('status', '=', 'active')->latest()->paginate();

Use pipelines for multi-step processes where data flows through stages:

namespace App\Pipelines\Order;
use Illuminate\Pipeline\Pipeline;
class OrderProcessingPipeline
{
public function __construct(
private Pipeline $pipeline
) {}
public function process(Order $order): Order
{
return $this->pipeline
->send($order)
->through([
ValidateInventory::class,
CalculateTaxes::class,
ApplyDiscounts::class,
ProcessPayment::class,
UpdateInventory::class,
SendNotifications::class,
])
->thenReturn();
}
}
// Pipeline Stage Example
class ValidateInventory
{
public function handle(Order $order, Closure $next): mixed
{
foreach ($order->items as $item) {
if ($item->product->inventory->stock < $item->quantity) {
throw new InsufficientStockException($item->product);
}
}
return $next($order);
}
}

Extract complex presentation logic from Blade views:

namespace App\Presenters;
use Chiiya\Common\Presenter\Presenter;
/**
* @extends Presenter<Product>
*/
class ProductPresenter extends Presenter
{
public function formattedPrice(): string
{
return number_format($this->entity->price, 2) . '';
}
public function discountPercentage(): ?int
{
if (! $this->entity->original_price) {
return null;
}
return (int) round(
(1 - $this->entity->price / $this->entity->original_price) * 100
);
}
public function statusBadgeClass(): string
{
return match($this->entity->status) {
'active' => 'badge-success',
'draft' => 'badge-warning',
'archived' => 'badge-secondary',
default => 'badge-default',
};
}
public function shareUrl(): string
{
return route('products.show', [
'product' => $this->entity->slug,
'ref' => 'share',
]);
}
}

Set up your model:

class Product extends Model
{
/** @use PresentableTrait<ProductPresenter> */
use PresentableTrait;
public string $presenter = ProductPresenter::class;
}

Usage in Blade:

<div class="product-card">
<h3>{{ $product->name }}</h3>
<p class="price">{{ $product->present()->formattedPrice() }}</p>
@if ($discount = $product->present()->discountPercentage())
<span class="discount">-{{ $discount }}%</span>
@endif
<span class="{{ $product->present()->statusBadgeClass() }}">
{{ $product->status }}
</span>
</div>

Use Laravel’s built-in patterns appropriately:

Events & Listeners:

// Event
class OrderPlaced
{
public function __construct(public Order $order) {}
}
// Listener
class SendOrderConfirmation
{
public function __construct(
private Mailer $mailer
) {}
public function handle(OrderPlaced $event): void
{
$this->mailer->to($event->order->user)->send(
new OrderConfirmationMail($event->order)
);
}
}
// Dispatch in Service
event(new OrderPlaced($order));

Jobs:

// For async/queued tasks
class ProcessOrderExport implements ShouldQueue
{
public function __construct(
public Order $order,
) {}
public function handle(ExportService $service): void
{
$service->exportOrder($this->order);
}
}
// Dispatch
$this->dispatch(new ProcessOrderExport($order));

Observers:

// For model lifecycle events
class ProductObserver
{
public function created(Product $product): void
{
// Generate slug, create default inventory, etc.
}
public function updating(Product $product): void
{
// Validate state transitions
}
}
// Register on the model
#[ObservedBy(ProductObserver::class)]
class Product extends Model {}

Policies:

// Authorization logic
class ProductPolicy
{
public function update(User $user, Product $product): bool
{
return $user->id === $product->user_id
|| $user->hasRole('admin');
}
}

Always try to use dependency injection instead of facades:

// ❌ Don't
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
class OrderService
{
public function sendConfirmation(Order $order): void
{
Mail::to($order->user)->send(new OrderConfirmation($order));
Log::debug('Mail sent');
}
}
// ✅ Do
use Illuminate\Contracts\Mail\Mailer;
use Psr\Log\LoggerInterface;
class OrderService
{
public function __construct(
private Mailer $mailer,
private LoggerInterface $logger,
) {}
public function sendConfirmation(Order $order): void
{
$this->mailer->to($order->user)->send(new OrderConfirmation($order));
$this->logger->debug('Mail sent');
}
}

This makes testing easier and dependencies explicit.

Organize your code logically:

app/
├── Console/
├── Events/
├── Exceptions/
├── Http/
│ ├── Controllers/
│ ├── Middleware/
│ └── Requests/
├── Jobs/
├── Listeners/
├── Mail/
├── Models/
├── Observers/
├── Pipelines/
├── Policies/
├── Presenters/
├── Providers/
├── Repositories/
└── Services/
├── Payment/
├── Notification/
└── Export/

When using SVG icons, consider implementing them through Blade Icons.

Installation:

Terminal window
composer require blade-ui-kit/blade-icons

Using Icon Sets:

Terminal window
# Install popular icon sets
composer require mallardduck/blade-lucide-icons

Usage in Blade:

<x-lucide-home class="w-5 h-5" />

Custom Icons:

  1. Create a directory: resources/svg/custom/
  2. Add your SVG files (e.g., logo.svg)
  3. Register in config/blade-icons.php:
'sets' => [
'custom' => [
'path' => 'resources/svg/custom',
'prefix' => 'icon',
],
],
  1. Use in Blade:
<x-icon-logo class="w-8 h-8" />

Track all model changes with Laravel Auditing.

Installation:

Terminal window
composer require owen-it/laravel-auditing
php artisan vendor:publish --provider "OwenIt\Auditing\AuditingServiceProvider" --tag="migrations"
php artisan migrate

Usage:

use OwenIt\Auditing\Contracts\Auditable;
use OwenIt\Auditing\Auditable as AuditableTrait;
class Product extends Model implements Auditable
{
use AuditableTrait;
// Customize which attributes to audit
protected $auditInclude = ['name', 'price', 'status'];
// Or exclude specific ones
protected $auditExclude = ['updated_at'];
}

Use Spatie Laravel Query Builder for consistent API filtering, sorting, and including relationships.

Installation:

Terminal window
composer require spatie/laravel-query-builder

Usage:

use Spatie\QueryBuilder\QueryBuilder;
class ProductController extends Controller
{
public function index()
{
$products = QueryBuilder::for(Product::class)
->allowedFilters(['name', 'category', 'status'])
->allowedSorts(['name', 'price', 'created_at'])
->allowedIncludes(['category', 'reviews'])
->paginate()
->appends(request()->query());
return response()->json($products);
}
}

API Call:

GET /api/products?filter[status]=active&sort=-price&include=category

Use Laravel Event Store to pass analytics events to the frontend.

Installation:

Terminal window
composer require antwerpes/laravel-event-store

You must also add the middleware to your web group, at the end of the stack:

protected $middlewareGroups = [
'web' => [
...
\Antwerpes\LaravelEventStore\Middleware\FlashEventStore::class,
],
];

Usage:

use Antwerpes\LaravelEventStore\Facades\EventStore;
// In your service or controller
EventStore::push('product_viewed', [
'product_id' => $product->id,
'category' => $product->category->name,
]);
// In your Blade layout
{!! EventStore::dumpForGTM() !!}

Automatically clean temporary files with Laravel Directory Cleanup.

Installation:

Terminal window
composer require spatie/laravel-directory-cleanup

Configuration:

config/laravel-directory-cleanup.php
return [
'directories' => [
[
'path' => storage_path('app/temp'),
'deleteAllOlderThanMinutes' => 60 * 24, // 24 hours
],
[
'path' => storage_path('app/exports'),
'deleteAllOlderThanMinutes' => 60 * 24 * 7, // 7 days
],
],
];

Schedule:

app/Console/Kernel.php
protected function schedule(Schedule $schedule)
{
$schedule->command('clean:directories')->daily();
}

Generate XML sitemaps with Laravel Sitemap.

Installation:

Terminal window
composer require spatie/laravel-sitemap

Usage:

use Spatie\Sitemap\Sitemap;
use Spatie\Sitemap\Tags\Url;
Sitemap::create()
->add(Url::create('/')->setPriority(1.0))
->add(Url::create('/about')->setPriority(0.8))
->add(Product::all()) // Implements Spatie\Sitemap\Contracts\Sitemapable
->writeToFile(public_path('sitemap.xml'));

Scheduled Generation:

protected function schedule(Schedule $schedule)
{
$schedule->command('sitemap:generate')->daily();
}

Happy coding!