Who this is for: PHP developers who know basic Laravel routes and want to read database records with Eloquent.
What you will learn: how a model maps to a table, how to filter and order a query, how pagination passes results to a Blade view, and how route model binding can find a record by its slug.
Prerequisites: Basic PHP, Laravel routing, and SQL SELECT statements. No database changes are made by this lesson.
Tested environment: Laravel Framework 13.16.1 and PHP 8.4.15. The walkthrough follows TeachyLeaf's blog controller, model, route, and SQLite-backed feature-test setup. The production database driver may differ.
Start with the model and table
TeachyLeaf's App\Models\Blog model represents records in the conventional blogs table. The migration defines columns including title, slug, content, published_at, and status. It also makes slug unique, so two rows cannot use the same slug.
The model casts the publication and content-updated timestamps to date objects. That lets PHP code and Blade format those values as dates instead of treating them as untyped strings.
Filter, sort, and paginate the blog index
The public blog controller uses this query in BlogController::index:
$blogs = Blog::where('status', 'Published')
->orderByDesc('published_at')
->paginate(6);
return view('frontend.blog', compact('blogs'));
Read it from top to bottom:
Blog::where(...)starts an Eloquent query for blog rows and keeps rows whose status is exactlyPublished.orderByDesc('published_at')asks for the newest publication timestamps first.paginate(6)returns up to six records for the current page and includes pagination information.- The controller passes the paginator to the view as
$blogs.
For example, given the illustrative records below, the index query returns the two published rows and excludes the draft. It sorts the published rows by publication timestamp.
| Title | Status | Published at | Shown in this example |
|---|---|---|---|
| SQL joins guide | Published | 2026-11-08 10:00 | Yes, first |
| Laravel request guide | Published | 2026-11-07 10:00 | Yes, second |
| Draft validation notes | Draft | 2026-11-09 10:00 | No |
These are made-up example rows to explain the filter and ordering; they are not claims about the site's current database contents.
Render a page of results
The blog Blade view loops through the paginator and renders each result. It also checks whether another page exists before displaying navigation:
@forelse ($blogs as $blog)
<article>
<h2>{{ $blog->title }}</h2>
<p>{{ $blog->excerpt }}</p>
<a href="{{ route('blog.show', $blog) }}">Read article</a>
</article>
@empty
<p>No published blog posts yet.</p>
@endforelse
@if ($blogs->hasPages())
{{ $blogs->links() }}
@endif
The actual view uses custom previous/next controls rather than Laravel's default links() markup. The example shows the same paginator API in a shorter form. Blade's escaped {{ ... }} output is appropriate for plain text such as titles and excerpts.
Open a post using its slug
The public route declares a custom route key:
Route::get('/blog/{blog:slug}', [BlogController::class, 'show'])
->name('blog.show');
The controller receives the resolved model and prevents non-published records from being shown:
public function show(Blog $blog)
{
abort_if($blog->status !== 'Published', 404);
return view('frontend.blog-details', compact('blog'));
}
Because the route uses {blog:slug}, Laravel resolves Blog $blog by the slug column rather than the numeric ID. If no matching slug exists, implicit model binding returns a not-found response. The controller's status check also returns 404 for a draft or archived post.
Understand what “Published” means here
In the current index query, status = 'Published' is the only publication filter. The query does not check whether published_at is in the past, and the column is nullable. So a row marked Published can appear even if its timestamp is in the future or empty; database drivers can also differ in how they order null timestamps.
If the site later needs scheduled publication, define that behavior explicitly (for example, require a non-null timestamp no later than the current time) and apply the same rule to the index, detail route, and sitemap. Do not assume the status label schedules posts by itself.
Pagination limits the number of records returned per page, but it does not replace query limits or filters for a different use case. Always verify ordering and null behavior with the database driver used in deployment.
Try it in this project
- Inspect the public routes with
php artisan route:list --path=blog -vv. - Compare
BlogController::indexwith the query above and follow the returned view name toresources/views/frontend/blog.blade.php. - Read the
blogsmigration and confirm thatslughas a unique constraint. - Run the feature tests with
php artisan test. The project's PHPUnit configuration uses an in-memory SQLite database; this verifies application behavior in tests, not the production database configuration.