TeachyLeaf library

What are you looking for?

Search practical tutorials and browser-based tools.

Laravel · PHP · Beginner

Laravel Eloquent queries: read and paginate published posts

Follow a real TeachyLeaf blog query from the Eloquent model to the paginated page and a post opened by its slug.

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:

  1. Blog::where(...) starts an Eloquent query for blog rows and keeps rows whose status is exactly Published.
  2. orderByDesc('published_at') asks for the newest publication timestamps first.
  3. paginate(6) returns up to six records for the current page and includes pagination information.
  4. 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.

Illustrative records and expected blog-index results
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

  1. Inspect the public routes with php artisan route:list --path=blog -vv.
  2. Compare BlogController::index with the query above and follow the returned view name to resources/views/frontend/blog.blade.php.
  3. Read the blogs migration and confirm that slug has a unique constraint.
  4. 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.

References

Continue learning

Review how a Laravel request reaches its controller, then follow validation on the real contact form. For SQL concepts used by Eloquent queries, continue with SELECT and WHERE filtering.