سی ام اس نویسسی ام اس نویسیاد بگیر، سیستم خودتو بنویس
مجلهتماس با ما
سی ام اس نویسسی ام اس نویس

CMS نویس مرجعی برای آموزش برنامه نویسی وب و PHP، توسعه افزونه‌های اختصاصی وردپرس، طراحی ورک‌فلو و اتوماسیون با n8n و توسعه CMS های اختصاصی است. هدف ما ارائه آموزش‌های کاربردی و راهکارهای حرفه‌ای برای توسعه‌دهندگان، کسب‌وکارها و علاقه‌مندان به دنیای برنامه‌نویسی است.

پیوندهای سریع
محصولات فیزیکیمحصولات دیجیتالدوره‌های آموزشی
سی ام اس نویس
مجله سی ام اس نویستماس با ماپنل کاربری
سی ام اس نویس — تمامی حقوق برای سی ام اس نویس محفوظ است. © 1406 تماس با ما09307711941
ساخت CMS با Laravel 12؛ آموزش ساخت سیستم مدیریت محتوا از صفر

ساخت CMS با Laravel 12؛ آموزش ساخت سیستم مدیریت محتوا از صفر

ممدیر کل سیستم ۲۷ مهر ۱۴۰۵ ۲۴ دقیقه مطالعه ۱ بازدید
ساخت CMS با Laravel 12؛ آموزش ساخت سیستم مدیریت محتوا از صفر

سیستم مدیریت محتوا یا CMS یکی از مهم‌ترین ابزارهای توسعه وب است. با استفاده از یک CMS می‌توان نوشته‌ها، صفحات، تصاویر، دسته‌بندی‌ها، کاربران و محتوای یک وب‌سایت را بدون نیاز به ویرایش مستقیم کدهای برنامه مدیریت کرد.

وردپرس یکی از شناخته‌شده‌ترین نمونه‌های سیستم مدیریت محتوا است؛ اما در بعضی پروژه‌ها، امکانات موردنیاز با ساختار CMSهای آماده هماهنگ نیست. در چنین شرایطی، توسعه یک سیستم مدیریت محتوای اختصاصی می‌تواند انتخاب مناسبی باشد.

در این مقاله یاد می‌گیریم چگونه با Laravel 12 یک CMS ساده اما قابل توسعه بسازیم. در طول آموزش، بخش‌های اصلی سیستم را پیاده‌سازی می‌کنیم؛ از طراحی دیتابیس و مدل‌های Eloquent گرفته تا ثبت نوشته، مدیریت دسته‌بندی، آپلود تصویر، انتشار محتوا و نمایش صفحات عمومی.

هدف این نیست که تمام امکانات یک CMS تجاری را در یک مقاله پیاده‌سازی کنیم؛ بلکه می‌خواهیم هسته‌ای اصولی بسازیم که بتوان آن را در پروژه‌های واقعی توسعه داد.

CMS چیست و چرا با Laravel آن را بسازیم؟

CMS مخفف Content Management System و به معنای سیستم مدیریت محتوا است. این نرم‌افزار به مدیران و نویسندگان اجازه می‌دهد محتوای وب‌سایت را از طریق یک پنل مدیریتی ایجاد و ویرایش کنند.

یک CMS معمولاً امکانات زیر را دارد:

  • ایجاد، ویرایش و حذف نوشته‌ها و صفحات
  • مدیریت دسته‌بندی‌ها و برچسب‌ها
  • بارگذاری و مدیریت تصاویر و فایل‌ها
  • تعیین وضعیت پیش‌نویس یا انتشار محتوا
  • مدیریت کاربران و سطح دسترسی
  • تعیین عنوان و توضیحات سئو
  • نمایش نوشته‌ها در سایت و ایجاد مسیرهای قابل‌فهم برای موتورهای جست‌وجو

Laravel برای این نوع پروژه مناسب است؛ زیرا امکاناتی مانند Eloquent ORM، اعتبارسنجی درخواست‌ها، مسیریابی، احراز هویت، مدیریت فایل و تست‌نویسی را در اختیار توسعه‌دهنده قرار می‌دهد.

برای مثال، می‌توان CMS را ابتدا برای یک وبلاگ طراحی کرد و سپس امکاناتی مانند مدیریت صفحات ثابت، چندزبانه‌بودن، سیستم نظرات، نقش‌های مختلف کاربران و API را به آن افزود.

۱. ایجاد پروژه CMS با Laravel 12

ابتدا باید محیط توسعه PHP و Composer روی سیستم نصب باشد. سپس در ترمینال دستور زیر را اجرا کنید:


composer create-project laravel/laravel cms-nevis "^12.0"

وارد پوشه پروژه شوید:


cd cms-nevis

برای اجرای محیط توسعه Laravel از دستور زیر استفاده کنید:


php artisan serve

اکنون برنامه معمولاً در نشانی زیر در دسترس خواهد بود:


http://127.0.0.1:8000

اگر می‌خواهید از MySQL استفاده کنید، یک دیتابیس برای پروژه ایجاد کنید و تنظیمات فایل .env را مطابق اطلاعات اتصال خود تغییر دهید:


APP_NAME="CMS Nevis"

APP_ENV=local

APP_DEBUG=true

APP_URL=http://127.0.0.1:8000



DB_CONNECTION=mysql

DB_HOST=127.0.0.1

DB_PORT=3306

DB_DATABASE=cms_nevis

DB_USERNAME=root

DB_PASSWORD=

مقادیر دیتابیس، نام کاربری و رمز عبور باید با محیط واقعی شما مطابقت داشته باشند. در محیط عملیاتی، فعال‌بودن APP_DEBUG می‌تواند اطلاعات حساس را افشا کند؛ بنابراین باید غیرفعال باشد.

بعد از تنظیم اتصال، جدول‌های اولیه Laravel را ایجاد کنید:


php artisan migrate

Laravel برای مدیریت ساختار دیتابیس از Migration استفاده می‌کند. این روش کمک می‌کند تغییرات دیتابیس به شکل نسخه‌بندی‌شده و قابل تکرار در محیط‌های مختلف اجرا شوند.

۲. طراحی دیتابیس سیستم مدیریت محتوا

قبل از نوشتن کد، باید مشخص کنیم CMS به چه اطلاعاتی نیاز دارد.

در نسخه آموزشی این پروژه، از جدول‌های زیر استفاده می‌کنیم:

جدولکاربرد
usersکاربران و مدیران سیستم
categoriesدسته‌بندی نوشته‌ها
postsاطلاعات اصلی نوشته‌ها
tagsبرچسب‌های محتوا
post_tagارتباط چندبه‌چند نوشته‌ها و برچسب‌ها

برای ساده‌ماندن پروژه، هر نوشته در این نسخه فقط یک دسته‌بندی اصلی دارد؛ اما می‌تواند چند برچسب داشته باشد.

ایجاد مدل‌ها و Migrationها

دستورهای زیر را اجرا کنید:


php artisan make:model Category -m

php artisan make:model Post -m

php artisan make:model Tag -m

هر دستور یک مدل و فایل Migration مربوط به آن ایجاد می‌کند.

طراحی جدول دسته‌بندی‌ها

فایل Migration مربوط به categories را باز کنید و ساختار آن را به شکل زیر تنظیم کنید:


Schema::create('categories', function (Blueprint $table) {

    $table->id();

    $table->string('name');

    $table->string('slug')->unique();

    $table->text('description')->nullable();

    $table->timestamps();

});

فیلد name نام دسته‌بندی را نگه می‌دارد و slug برای ساخت آدرس قابل‌خواندن استفاده می‌شود.

برای نمونه، دسته‌بندی «آموزش لاراول» می‌تواند چنین مسیری داشته باشد:


/categories/laravel-tutorials

طراحی جدول نوشته‌ها

در Migration مدل Post، ساختار زیر را قرار دهید:


Schema::create('posts', function (Blueprint $table) {

    $table->id();



    $table->foreignId('user_id')

        ->constrained()

        ->cascadeOnDelete();



    $table->foreignId('category_id')

        ->nullable()

        ->constrained()

        ->nullOnDelete();



    $table->string('title');

    $table->string('slug')->unique();

    $table->longText('body');



    $table->string('excerpt', 500)->nullable();

    $table->string('featured_image')->nullable();



    $table->string('status')->default('draft');

    $table->string('meta_title')->nullable();

    $table->string('meta_description', 320)->nullable();



    $table->timestamp('published_at')->nullable();



    $table->timestamps();

    $table->softDeletes();



    $table->index(['status', 'published_at']);

});

در این ساختار:

  • user_id نویسنده نوشته را مشخص می‌کند.
  • category_id دسته‌بندی اصلی را نگه می‌دارد.
  • title عنوان نوشته است.
  • slug بخش اختصاصی آدرس نوشته است.
  • body محتوای اصلی را نگه می‌دارد.
  • excerpt خلاصه نوشته است.
  • featured_image مسیر تصویر شاخص را نگه می‌دارد.
  • status وضعیت انتشار را مشخص می‌کند.
  • meta_title و meta_description برای تنظیم اطلاعات سئو استفاده می‌شوند.
  • published_at زمان انتشار را نگه می‌دارد.
  • softDeletes() امکان حذف نرم را فراهم می‌کند؛ یعنی رکورد به‌جای حذف فوری، به‌عنوان حذف‌شده علامت‌گذاری می‌شود.

در این مثال، وضعیت نوشته را با مقادیر draft و published مدیریت می‌کنیم. در پروژه‌های پیشرفته‌تر می‌توان وضعیت‌هایی مانند scheduled یا archived را نیز تعریف کرد.

طراحی جدول برچسب‌ها

در Migration مربوط به Tag بنویسید:


Schema::create('tags', function (Blueprint $table) {

    $table->id();

    $table->string('name');

    $table->string('slug')->unique();

    $table->timestamps();

});

سپس جدول واسط ارتباط نوشته‌ها و برچسب‌ها را بسازید:


php artisan make:migration create_post_tag_table

در فایل ایجادشده:


Schema::create('post_tag', function (Blueprint $table) {

    $table->id();



    $table->foreignId('post_id')

        ->constrained()

        ->cascadeOnDelete();



    $table->foreignId('tag_id')

        ->constrained()

        ->cascadeOnDelete();



    $table->unique(['post_id', 'tag_id']);

});

وجود محدودیت یکتا از ثبت ارتباط تکراری بین یک نوشته و یک برچسب جلوگیری می‌کند.

در نهایت، Migrationها را اجرا کنید:


php artisan migrate

۳. ساخت مدل‌های Eloquent

اکنون باید مدل‌ها را به هم مرتبط کنیم تا دسترسی به نویسنده، دسته‌بندی و برچسب‌های هر نوشته ساده شود.

مدل Post

فایل app/Models/Post.php را باز کنید:


<?php



namespace App\Models;



use Illuminate\Database\Eloquent\Model;

use Illuminate\Database\Eloquent\SoftDeletes;

use Illuminate\Database\Eloquent\Relations\BelongsTo;

use Illuminate\Database\Eloquent\Relations\BelongsToMany;



class Post extends Model

{

    use SoftDeletes;



    protected $fillable = [

        'user_id',

        'category_id',

        'title',

        'slug',

        'body',

        'excerpt',

        'featured_image',

        'status',

        'meta_title',

        'meta_description',

        'published_at',

    ];



    protected function casts(): array

    {

        return [

            'published_at' => 'datetime',

        ];

    }



    public function author(): BelongsTo

    {

        return $this->belongsTo(User::class, 'user_id');

    }



    public function category(): BelongsTo

    {

        return $this->belongsTo(Category::class);

    }



    public function tags(): BelongsToMany

    {

        return $this->belongsToMany(Tag::class);

    }



    public function scopePublished($query)

    {

        return $query

            ->where('status', 'published')

            ->whereNotNull('published_at')

            ->where('published_at', '<=', now());

    }

}

متد fillable مشخص می‌کند کدام فیلدها اجازه دارند از طریق عملیات mass assignment مقداردهی شوند. بااین‌حال، این ویژگی جایگزین بررسی مجوزهای کاربر نیست.

متد scopePublished نیز یک شرط مشترک برای دریافت نوشته‌های منتشرشده تعریف می‌کند.

مدل Category

فایل app/Models/Category.php:


<?php



namespace App\Models;



use Illuminate\Database\Eloquent\Model;

use Illuminate\Database\Eloquent\Relations\HasMany;



class Category extends Model

{

    protected $fillable = [

        'name',

        'slug',

        'description',

    ];



    public function posts(): HasMany

    {

        return $this->hasMany(Post::class);

    }

}

مدل Tag

فایل app/Models/Tag.php:


<?php



namespace App\Models;



use Illuminate\Database\Eloquent\Model;

use Illuminate\Database\Eloquent\Relations\BelongsToMany;



class Tag extends Model

{

    protected $fillable = [

        'name',

        'slug',

    ];



    public function posts(): BelongsToMany

    {

        return $this->belongsToMany(Post::class);

    }

}

با تعریف این روابط می‌توانیم بدون نوشتن کوئری‌های دستی، نوشته‌های هر دسته‌بندی یا برچسب‌های هر نوشته را دریافت کنیم.

۴. ساخت Controllerهای CMS

برای مدیریت نوشته‌ها و نمایش عمومی آن‌ها، دو Controller ایجاد می‌کنیم:


php artisan make:controller Admin/PostController --resource

php artisan make:controller PostController

Controller اول مسئول عملیات مدیریتی است و Controller دوم نوشته‌ها را در بخش عمومی سایت نمایش می‌دهد.

در این نمونه از Blade برای ساخت صفحات استفاده می‌کنیم. بنابراین نیازی نیست برای شروع پروژه سراغ معماری پیچیده یا فریم‌ورک JavaScript جداگانه برویم.

۵. تعریف مسیرهای عمومی و مدیریتی

فایل routes/web.php را باز کنید.

ابتدا مسیرهای عمومی را تعریف می‌کنیم:


use App\Http\Controllers\PostController;

use App\Http\Controllers\Admin\PostController as AdminPostController;

use Illuminate\Support\Facades\Route;



Route::get('/', [PostController::class, 'index'])

    ->name('home');



Route::get('/posts/{post:slug}', [PostController::class, 'show'])

    ->name('posts.show');

در مسیر دوم از {post:slug} استفاده کرده‌ایم تا نوشته براساس مقدار slug پیدا شود، نه شناسه عددی آن.

اکنون مسیرهای مدیریتی را اضافه کنید:


Route::middleware(['auth'])

    ->prefix('admin')

    ->name('admin.')

    ->group(function () {

        Route::resource('posts', AdminPostController::class);

    });

این گروه مسیرهای اصلی ایجاد، نمایش، ویرایش و حذف نوشته‌ها را در اختیار پنل مدیریت قرار می‌دهد.

نکته امنیتی: میان‌افزار auth فقط ورود کاربر را بررسی می‌کند. در پروژه واقعی باید با Policy یا مجوزهای مبتنی بر نقش مشخص شود چه کسی اجازه ایجاد، ویرایش، انتشار و حذف نوشته‌ها را دارد. همچنین باید مطمئن شوید سیستم احراز هویت پروژه از قبل راه‌اندازی شده است.

۶. نمایش فهرست نوشته‌های منتشرشده

فایل app/Http/Controllers/PostController.php را باز کنید:


<?php



namespace App\Http\Controllers;



use App\Models\Post;

use Illuminate\View\View;



class PostController extends Controller

{

    public function index(): View

    {

        $posts = Post::published()

            ->with(['author', 'category'])

            ->latest('published_at')

            ->paginate(10);



        return view('posts.index', compact('posts'));

    }



    public function show(Post $post): View

    {

        abort_unless(

            $post->status === 'published'

            && $post->published_at !== null

            && $post->published_at->lte(now()),

            404

        );



        $post->load(['author', 'category', 'tags']);



        return view('posts.show', compact('post'));

    }

}

در این Controller از with() برای بارگذاری روابط استفاده شده است. این کار در فهرست نوشته‌ها کمک می‌کند از اجرای کوئری جداگانه برای نویسنده و دسته‌بندی هر نوشته جلوگیری شود.

همچنین بررسی وضعیت انتشار در متد show باعث می‌شود کاربران عادی نتوانند با دانستن آدرس یک نوشته، محتوای پیش‌نویس یا محتوای زمان‌بندی‌شده را مشاهده کنند.

ساخت نمای فهرست نوشته‌ها

پوشه‌های زیر را ایجاد کنید:


resources/views/posts/

سپس فایل resources/views/posts/index.blade.php را بسازید:


<!DOCTYPE html>

<html lang="fa" dir="rtl">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">

    <title>مقالات سایت</title>

</head>

<body>

    <main>

        <h1>آخرین مقالات</h1>



        @forelse ($posts as $post)

            <article>

                <h2>

                    <a href="{{ route('posts.show', $post->slug) }}">

                        {{ $post->title }}

                    </a>

                </h2>



                @if ($post->category)

                    <p>دسته‌بندی: {{ $post->category->name }}</p>

                @endif



                <p>

                    {{ $post->excerpt ?: \Illuminate\Support\Str::limit(strip_tags($post->body), 180) }}

                </p>



                <a href="{{ route('posts.show', $post->slug) }}">

                    ادامه مطلب

                </a>

            </article>

        @empty

            <p>هنوز مقاله‌ای منتشر نشده است.</p>

        @endforelse



        {{ $posts->links() }}

    </main>

</body>

</html>

این قالب یک نسخه ساده برای نمایش مقالات است. در طراحی نهایی می‌توانید از Blade Layout، فایل‌های CSS، Tailwind CSS و کامپوننت‌های قابل استفاده مجدد بهره ببرید.

ساخت صفحه جزئیات نوشته

فایل resources/views/posts/show.blade.php را ایجاد کنید:


<!DOCTYPE html>

<html lang="fa" dir="rtl">

<head>

    <meta charset="UTF-8">

    <meta name="viewport" content="width=device-width, initial-scale=1">



    <title>

        {{ $post->meta_title ?: $post->title }}

    </title>



    @if ($post->meta_description)

        <meta

            name="description"

            content="{{ $post->meta_description }}"

        >

    @endif

</head>

<body>

    <main>

        <article>

            <h1>{{ $post->title }}</h1>



            @if ($post->author)

                <p>نویسنده: {{ $post->author->name }}</p>

            @endif



            @if ($post->category)

                <p>دسته‌بندی: {{ $post->category->name }}</p>

            @endif



            @if ($post->featured_image)

                <img

                    src="{{ asset('storage/' . $post->featured_image) }}"

                    alt="{{ $post->title }}"

                    loading="lazy"

                >

            @endif



            <div>

                {{ $post->body }}

            </div>



            @if ($post->tags->isNotEmpty())

                <h2>برچسب‌ها</h2>



                @foreach ($post->tags as $tag)

                    <span>{{ $tag->name }}</span>

                @endforeach

            @endif

        </article>

    </main>

</body>

</html>

در این نمونه، محتوای نوشته به‌صورت متن ساده نمایش داده می‌شود. اگر قرار است ویرایشگر محتوای غنی مانند HTML داشته باشید، باید نحوه ذخیره‌سازی و نمایش HTML را به‌صورت جداگانه و با پاک‌سازی ورودی‌ها طراحی کنید.

هشدار امنیتی: خروجی Blade با {{ }} به‌صورت خودکار Escape می‌شود. این رفتار را برای داده‌های متنی حفظ کنید. نمایش مستقیم محتوای ذخیره‌شده با {!! !!} بدون پاک‌سازی HTML می‌تواند آسیب‌پذیری XSS ایجاد کند.

۷. ساخت فرم ایجاد و ویرایش نوشته

در این مرحله، باید امکان ثبت نوشته از طریق پنل مدیریت را ایجاد کنیم.

ابتدا متدهای create و store را در app/Http/Controllers/Admin/PostController.php قرار دهید:


<?php



namespace App\Http\Controllers\Admin;



use App\Http\Controllers\Controller;

use App\Models\Category;

use App\Models\Post;

use Illuminate\Http\Request;

use Illuminate\Support\Facades\Auth;

use Illuminate\Support\Str;

use Illuminate\Validation\Rule;



class PostController extends Controller

{

    public function index()

    {

        $posts = Post::with('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)

    {

        $data = $request->validate([

            'title' => ['required', 'string', 'max:255'],

            'slug' => ['nullable', 'string', 'max:255', 'alpha_dash', 'unique:posts,slug'],

            'body' => ['required', 'string'],

            'excerpt' => ['nullable', 'string', 'max:500'],

            'category_id' => ['nullable', 'exists:categories,id'],

            'meta_title' => ['nullable', 'string', 'max:255'],

            'meta_description' => ['nullable', 'string', 'max:320'],

            'status' => ['required', Rule::in(['draft', 'published'])],

        ]);



        $data['slug'] = filled($data['slug'])

            ? $data['slug']

            : Str::slug($data['title']) . '-' . Str::lower(Str::random(6));



        $data['user_id'] = Auth::id();



        $data['published_at'] = $data['status'] === 'published'

            ? now()

            : null;



        $post = Post::create($data);



        return redirect()

            ->route('admin.posts.edit', $post)

            ->with('success', 'نوشته با موفقیت ایجاد شد.');

    }



    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)

    {

        $data = $request->validate([

            'title' => ['required', 'string', 'max:255'],

            'slug' => [

                'required',

                'string',

                'max:255',

                'alpha_dash',

                Rule::unique('posts', 'slug')->ignore($post->id),

            ],

            'body' => ['required', 'string'],

            'excerpt' => ['nullable', 'string', 'max:500'],

            'category_id' => ['nullable', 'exists:categories,id'],

            'meta_title' => ['nullable', 'string', 'max:255'],

            'meta_description' => ['nullable', 'string', 'max:320'],

            'status' => ['required', Rule::in(['draft', 'published'])],

        ]);



        if ($data['status'] === 'published' && ! $post->published_at) {

            $data['published_at'] = now();

        }



        if ($data['status'] === 'draft') {

            $data['published_at'] = null;

        }



        $post->update($data);



        return redirect()

            ->route('admin.posts.edit', $post)

            ->with('success', 'نوشته با موفقیت به‌روزرسانی شد.');

    }



    public function destroy(Post $post)

    {

        $post->delete();



        return redirect()

            ->route('admin.posts.index')

            ->with('success', 'نوشته حذف شد.');

    }

}

این Controller بخش مهمی از عملیات CRUD را پیاده‌سازی می‌کند. بااین‌حال، برای تکمیل پنل باید قالب‌های Blade مربوط به فهرست، ایجاد و ویرایش نوشته را نیز بسازید و فرم‌ها را به متدهای بالا متصل کنید.

در فرم‌های POST، PUT و DELETE باید از توکن CSRF و متدهای مناسب فرم استفاده شود. در Blade، معمولاً از @csrf و در فرم‌های ویرایش یا حذف از @method('PUT') یا @method('DELETE') استفاده می‌کنیم.

چرا slug را خودکار تولید می‌کنیم؟

فرض کنید عنوان نوشته چنین باشد:


آموزش ساخت CMS با Laravel

می‌خواهیم یک مسیر خوانا برای آن داشته باشیم. متد Str::slug() می‌تواند یک slug مناسب تولید کند؛ اما برای عنوان‌های فارسی و نیازهای زبانی پروژه، خروجی را باید بررسی کنید.

در کد بالا، یک پسوند تصادفی نیز اضافه شده تا احتمال برخورد slugهای تولیدشده خودکار کمتر شود. اگر کاربر خودش slug وارد کند، سیستم آن را اعتبارسنجی می‌کند و یکتا بودن آن را می‌سنجد.

محدودیت unique در اعتبارسنجی و قید unique در دیتابیس هر دو اهمیت دارند. در پروژه‌های پرترافیک، باید خطای احتمالی برخورد هم‌زمان دو درخواست برای یک slug یکسان را نیز مدیریت کنید.

۸. ایجاد فرم ثبت نوشته در Blade

فایل resources/views/admin/posts/create.blade.php را ایجاد کنید:


<!DOCTYPE html>

<html lang="fa" dir="rtl">

<head>

    <meta charset="UTF-8">

    <title>ایجاد نوشته جدید</title>

</head>

<body>

    <main>

        <h1>ایجاد نوشته جدید</h1>



        @if ($errors->any())

            <ul>

                @foreach ($errors->all() as $error)

                    <li>{{ $error }}</li>

                @endforeach

            </ul>

        @endif



        <form

            action="{{ route('admin.posts.store') }}"

            method="POST"

        >

            @csrf



            <label for="title">عنوان نوشته</label>

            <input

                id="title"

                name="title"

                value="{{ old('title') }}"

                required

            >



            <label for="slug">نامک</label>

            <input

                id="slug"

                name="slug"

                value="{{ old('slug') }}"

            >



            <label for="category_id">دسته‌بندی</label>

            <select id="category_id" name="category_id">

                <option value="">بدون دسته‌بندی</option>



                @foreach ($categories as $category)

                    <option

                        value="{{ $category->id }}"

                        @selected(old('category_id') == $category->id)

                    >

                        {{ $category->name }}

                    </option>

                @endforeach

            </select>



            <label for="excerpt">خلاصه</label>

            <textarea

                id="excerpt"

                name="excerpt"

            >{{ old('excerpt') }}</textarea>



            <label for="body">متن نوشته</label>

            <textarea

                id="body"

                name="body"

                rows="15"

                required

            >{{ old('body') }}</textarea>



            <label for="meta_title">عنوان سئو</label>

            <input

                id="meta_title"

                name="meta_title"

                value="{{ old('meta_title') }}"

            >



            <label for="meta_description">توضیحات سئو</label>

            <textarea

                id="meta_description"

                name="meta_description"

            >{{ old('meta_description') }}</textarea>



            <label for="status">وضعیت</label>

            <select id="status" name="status" required>

                <option value="draft">پیش‌نویس</option>

                <option value="published">منتشرشده</option>

            </select>



            <button type="submit">ذخیره نوشته</button>

        </form>

    </main>

</body>

</html>

این فرم نسخه ساده‌ای برای شروع است. در پروژه واقعی، می‌توانید از ویرایشگر متن غنی استفاده کنید؛ اما خروجی آن باید قبل از نمایش عمومی از نظر HTML و اسکریپت‌های خطرناک بررسی و پاک‌سازی شود.

۹. آپلود تصویر شاخص نوشته‌ها

یک CMS معمولاً به تصاویر شاخص نیاز دارد. Laravel از طریق سیستم فایل خود امکان ذخیره فایل‌ها در دیسک‌های مختلف را فراهم می‌کند.

ابتدا مطمئن شوید دیسک عمومی در config/filesystems.php پیکربندی شده است. در محیط معمول Laravel، می‌توان لینک دسترسی عمومی را با دستور زیر ایجاد کرد:


php artisan storage:link

حالا در متد store کنترلر، قوانین اعتبارسنجی تصویر را اضافه کنید:


'featured_image' => [

    'nullable',

    'image',

    'mimes:jpg,jpeg,png,webp',

    'max:2048',

],

سپس پیش از ذخیره نوشته، فایل را مدیریت کنید:


if ($request->hasFile('featured_image')) {

    $data['featured_image'] = $request

        ->file('featured_image')

        ->store('posts', 'public');

}

همین منطق را برای متد update نیز پیاده‌سازی کنید تا مدیر بتواند تصویر نوشته را تغییر دهد.

فیلد featured_image در دیتابیس باید مسیر فایل را نگه دارد، نه خود فایل را.

برای نمایش تصویر در Blade می‌توان از مسیر ذخیره‌شده استفاده کرد:


@if ($post->featured_image)

    <img

        src="{{ asset('storage/' . $post->featured_image) }}"

        alt="{{ $post->title }}"

    >

@endif

در پروژه‌های عملیاتی باید محدودیت اندازه فایل، فرمت مجاز، دسترسی به فایل‌ها و سیاست حذف تصاویر قدیمی را نیز در نظر بگیرید. فایل‌های خصوصی نباید صرفاً با قرارگرفتن در مسیر عمومی قابل دانلود شوند.

۱۰. اضافه کردن برچسب‌ها به نوشته‌ها

در این مرحله مدل‌های نوشته و برچسب را به هم متصل کرده‌ایم؛ بنابراین می‌توانیم از متد sync() برای تنظیم ارتباط‌ها استفاده کنیم.

برای نمونه، اگر شناسه برچسب‌ها در فیلد tags ارسال شود، ابتدا باید آن را اعتبارسنجی کنیم:


'tags' => ['nullable', 'array'],

'tags.*' => ['integer', 'distinct', 'exists:tags,id'],

سپس بعد از ذخیره نوشته:


$post->tags()->sync($request->input('tags', []));

متد sync() ارتباط‌های موجود را با شناسه‌های ارسال‌شده هماهنگ می‌کند؛ یعنی برچسب‌هایی که در فهرست جدید نیستند از ارتباط نوشته حذف خواهند شد.

این کد باید در متدهای ایجاد و ویرایش نوشته قرار گیرد. اگر قرار است هم‌زمان نوشته و ارتباط‌های آن ذخیره شوند، استفاده از تراکنش دیتابیس می‌تواند از باقی‌ماندن داده‌های نیمه‌کاره در صورت بروز خطا جلوگیری کند.

۱۱. مدیریت دسترسی‌ها و امنیت پنل مدیریت

یکی از تفاوت‌های مهم CMS واقعی با یک فرم ساده ثبت نوشته، کنترل دقیق دسترسی‌ها است.

برای مثال، ممکن است در آینده نقش‌های زیر را داشته باشید:

  • مدیر کل: دسترسی کامل به تنظیمات و محتوا
  • ویراستار: بررسی و انتشار نوشته‌ها
  • نویسنده: ایجاد و ویرایش نوشته‌های خودش
  • کاربر عادی: فقط مشاهده محتوای منتشرشده

صرفاً قرار دادن مسیرها پشت auth کافی نیست؛ چون هر کاربر واردشده‌ای ممکن است در صورت نبود بررسی مجوزها، بتواند نوشته دیگران را تغییر دهد.

برای کنترل این موضوع می‌توانید از Policy استفاده کنید:


php artisan make:policy PostPolicy --model=Post

در Policy، مجوزهای مشاهده، ویرایش و حذف را براساس کاربر و نوشته بررسی کنید. برای نمونه، یک نویسنده ممکن است تنها اجازه ویرایش نوشته‌های متعلق به خودش را داشته باشد، درحالی‌که مدیر اجازه ویرایش همه نوشته‌ها را دارد.

سپس در کنترلر، قبل از عملیات حساس مجوز مناسب را بررسی کنید. الگوی متداول Laravel استفاده از متدهای authorize() یا میان‌افزارهای مجوزدهی است.

برای پروژه‌هایی که نقش‌ها و مجوزهای متعددی دارند، می‌توان از بسته‌های معتبر مدیریت نقش و دسترسی نیز استفاده کرد؛ اما قواعد دسترسی باید با نیاز واقعی پروژه هماهنگ باشند.

چند نکته امنیتی دیگر:

  1. تمام ورودی‌های کاربر را اعتبارسنجی کنید.
  2. برای فرم‌های وب از CSRF protection استفاده کنید.
  3. اطلاعات حساس را در کد یا مخزن عمومی قرار ندهید.
  4. در محیط عملیاتی APP_DEBUG=false باشد.
  5. برای عملیات حذف و انتشار، مجوز مستقل تعریف کنید.
  6. هنگام نمایش HTML ذخیره‌شده، جلوی XSS را بگیرید.
  7. روی آپلود فایل، محدودیت نوع، اندازه و دسترسی اعمال کنید.

۱۲. بهبود سئو در CMS اختصاصی

یکی از مزیت‌های ساخت CMS اختصاصی، امکان طراحی دقیق ساختار سئو متناسب با نیاز وب‌سایت است.

در این پروژه، دو فیلد meta_title و meta_description را برای نوشته‌ها تعریف کردیم. می‌توان این ساختار را با امکانات بیشتری توسعه داد.

عنوان و توضیحات متا

در قالب صفحه نوشته، عنوان متا را با این منطق تولید کردیم:


<title>{{ $post->meta_title ?: $post->title }}</title>

اگر عنوان سئو وارد نشده باشد، عنوان اصلی نوشته استفاده می‌شود.

توضیحات متا نیز در صورت وجود، در بخش head قرار می‌گیرد. توضیحات باید خلاصه، دقیق و مرتبط با محتوای صفحه باشند.

آدرس‌های خوانا

به‌جای مسیرهایی مانند:


/posts/125

می‌توان از مسیرهای خوانا استفاده کرد:


/posts/build-cms-with-laravel

برای این کار باید slugها یکتا باشند و در زمان تغییر slug یک نوشته منتشرشده، سیاست مناسبی برای ریدایرکت آدرس قدیمی در نظر گرفته شود.

نقشه سایت و صفحات قابل ایندکس

در نسخه کامل CMS بهتر است امکانات زیر نیز پیاده‌سازی شوند:

  • تولید sitemap برای نوشته‌ها و صفحات عمومی
  • ساخت canonical برای مدیریت نسخه اصلی هر صفحه
  • تنظیم robots در صورت نیاز
  • نمایش صفحات دسته‌بندی و برچسب با محتوای مرتبط
  • جلوگیری از ایندکس‌شدن پیش‌نویس‌ها و صفحات مدیریتی
  • ایجاد داده‌های ساختاریافته متناسب با نوع محتوا

وجود فیلدهای سئو به‌تنهایی تضمین‌کننده رتبه بهتر نیست؛ کیفیت محتوا، ساختار لینک‌ها، سرعت سایت، تجربه کاربری و دسترس‌پذیری صفحات نیز اهمیت دارند.

۱۳. بهینه‌سازی عملکرد CMS

با افزایش تعداد نوشته‌ها و کاربران، عملکرد سیستم اهمیت بیشتری پیدا می‌کند.

استفاده از صفحه‌بندی

در کد فهرست نوشته‌ها از این دستور استفاده کردیم:


Post::published()

    ->latest('published_at')

    ->paginate(10);

این روش به‌جای دریافت تمام نوشته‌ها، تعداد مشخصی از آن‌ها را برای هر صفحه برمی‌گرداند.

جلوگیری از مشکل N+1

اگر برای هر نوشته جداگانه اطلاعات نویسنده یا دسته‌بندی را درخواست کنیم، ممکن است تعداد کوئری‌ها به‌سرعت افزایش یابد.

استفاده از with() در نمونه ما باعث بارگذاری رابطه‌ها به شکل کارآمدتر می‌شود:


Post::published()

    ->with(['author', 'category'])

    ->latest('published_at')

    ->paginate(10);

ایندکس‌های دیتابیس

برای فیلدهایی که مرتب در شرط‌های جست‌وجو یا مرتب‌سازی استفاده می‌شوند، بررسی ایندکس مناسب می‌تواند مفید باشد. در Migration نوشته‌ها، ایندکسی روی status و published_at تعریف کردیم؛ اما ایندکس بهینه به نوع کوئری و حجم داده وابسته است و باید با بررسی عملکرد واقعی انتخاب شود.

کش کردن داده‌ها

برای بخش‌هایی مانند منوی دسته‌بندی‌ها یا تنظیمات عمومی که مرتب تغییر نمی‌کنند، می‌توان از Cache لاراول استفاده کرد. در این حالت باید هنگام تغییر محتوا، کش مربوط به آن به‌درستی باطل شود.

۱۴. تست کردن CMS

قبل از انتشار، باید رفتارهای اصلی سیستم را آزمایش کنید. Laravel ابزارهای تست مناسبی برای درخواست‌های HTTP و بررسی وضعیت دیتابیس دارد.

برای مثال، می‌توانید تستی بنویسید که مطمئن شود نوشته منتشرنشده برای کاربر عمومی قابل مشاهده نیست.

ابتدا یک تست ایجاد کنید:


php artisan make:test PublishedPostsTest

یک نمونه تست ساده:


<?php



namespace Tests\Feature;



use App\Models\Post;

use App\Models\User;

use Illuminate\Foundation\Testing\RefreshDatabase;

use Tests\TestCase;



class PublishedPostsTest extends TestCase

{

    use RefreshDatabase;



    public function test_draft_posts_are_not_publicly_visible(): void

    {

        $user = User::factory()->create();



        $post = Post::create([

            'user_id' => $user->id,

            'title' => 'Draft Article',

            'slug' => 'draft-article',

            'body' => 'This is a draft.',

            'status' => 'draft',

            'published_at' => null,

        ]);



        $response = $this->get(

            route('posts.show', $post->slug)

        );



        $response->assertNotFound();

    }

}

این تست فرض می‌کند مدل User و Factory پیش‌فرض آن در پروژه موجود است و Migrationهای لازم اجرا می‌شوند.

برای کامل‌تر شدن مجموعه تست‌ها، رفتارهای زیر را نیز بررسی کنید:

  • کاربر مهمان به پنل مدیریت دسترسی نداشته باشد.
  • نویسنده نتواند نوشته غیرمجاز را ویرایش کند.
  • ثبت نوشته بدون عنوان یا متن معتبر رد شود.
  • slug تکراری پذیرفته نشود.
  • نوشته منتشرشده در فهرست عمومی نمایش داده شود.
  • آپلود فایل با فرمت نامعتبر رد شود.
  • حذف نوشته و وضعیت حذف نرم درست عمل کند.

تست‌ها کمک می‌کنند هنگام اضافه‌شدن قابلیت‌های جدید، بخش‌های قبلی سیستم ناخواسته خراب نشوند.

۱۵. چه امکاناتی را می‌توان به CMS اضافه کرد؟

پس از پیاده‌سازی هسته اصلی، می‌توان سیستم را مرحله‌به‌مرحله توسعه داد.

مدیریت صفحات ثابت: برای صفحاتی مانند «درباره ما»، «تماس با ما» و «قوانین سایت».

سیستم نظرات: برای ثبت، بررسی و تأیید دیدگاه‌های کاربران.

ویرایشگر متن غنی: برای مدیریت تیترها، لینک‌ها، تصاویر، جدول‌ها و سایر عناصر محتوایی؛ با رعایت اصول امنیتی.

مدیریت نقش‌ها و مجوزها: برای تفکیک وظایف مدیر، نویسنده و ویراستار.

انتشار زمان‌بندی‌شده: برای نمایش خودکار نوشته‌ها در تاریخ مشخص و مدیریت پردازش‌های زمان‌بندی‌شده.

مدیریت رسانه: برای دسته‌بندی تصاویر، تولید نسخه‌های مختلف تصویر و مدیریت فایل‌های استفاده‌شده در محتوا.

جست‌وجوی پیشرفته: برای جست‌وجو براساس عنوان، متن، دسته‌بندی و برچسب.

چندزبانه‌بودن: برای نگهداری ترجمه‌های هر نوشته و تعیین آدرس مناسب برای زبان‌های مختلف.

API: برای اتصال CMS به اپلیکیشن موبایل، فرانت‌اند مستقل یا سرویس‌های دیگر.

گزارش‌گیری: برای مشاهده تعداد نوشته‌ها، وضعیت انتشار، فعالیت نویسندگان و داده‌های آماری.

لازم نیست همه این امکانات از روز اول ساخته شوند. بهتر است ابتدا نیازهای اصلی پروژه مشخص شوند و سپس قابلیت‌ها براساس اولویت و معماری مناسب اضافه شوند.

پرسش‌های متداول

آیا می‌توان با Laravel یک CMS شبیه وردپرس ساخت؟

بله. Laravel ابزارهای لازم برای ساخت سیستم مدیریت محتوا، مدیریت کاربران، نوشته‌ها، فایل‌ها و پنل مدیریت را فراهم می‌کند. بااین‌حال، برای رسیدن به امکانات گسترده وردپرس باید قابلیت‌هایی مانند افزونه‌ها، قالب‌ها، مدیریت رسانه، نقش‌ها و ابزارهای توسعه نیز طراحی و پیاده‌سازی شوند.

برای ساخت CMS با Laravel به چه مهارت‌هایی نیاز داریم؟

آشنایی با PHP، مفاهیم MVC، مسیریابی Laravel، Eloquent ORM، Migration، Blade، اعتبارسنجی فرم‌ها و اصول احراز هویت و مجوزدهی، نقطه شروع مناسبی است.

آیا می‌توان از MySQL برای دیتابیس CMS استفاده کرد؟

بله. Laravel از MySQL پشتیبانی می‌کند و می‌توان ساختار دیتابیس نوشته‌ها، دسته‌بندی‌ها، کاربران و برچسب‌ها را با Migration ایجاد کرد.

آیا CMS اختصاصی از وردپرس بهتر است؟

این موضوع به نیاز پروژه بستگی دارد. وردپرس برای بسیاری از وب‌سایت‌ها انتخابی سریع و اقتصادی است. CMS اختصاصی زمانی می‌تواند مناسب‌تر باشد که منطق تجاری، گردش کار یا ساختار محتوایی خاصی داشته باشید که با راهکارهای آماده به‌خوبی پوشش داده نمی‌شود.

چگونه امنیت پنل مدیریت را افزایش دهیم؟

با استفاده از احراز هویت استاندارد، Policy و مجوزهای مناسب، اعتبارسنجی ورودی‌ها، محافظت از فرم‌ها در برابر CSRF، پاک‌سازی HTMLهای قابل نمایش، مدیریت امن فایل‌ها و تست‌کردن دسترسی‌ها می‌توان امنیت سیستم را بهبود داد.

آیا این پروژه برای استفاده در محیط عملیاتی آماده است؟

این آموزش هسته آموزشی CMS را نشان می‌دهد. پیش از استفاده عملیاتی باید قالب‌های مدیریتی تکمیل شوند، مجوزهای دسترسی اعمال شوند، آپلود و مدیریت HTML امن‌سازی شوند و تست‌ها، مدیریت خطا، پشتیبان‌گیری و تنظیمات محیط عملیاتی انجام شوند.

جمع‌بندی

در این مقاله یاد گرفتیم چگونه ساخت یک سیستم مدیریت محتوا با Laravel 12 را آغاز کنیم. ابتدا ساختار دیتابیس را طراحی کردیم، سپس مدل‌های Eloquent، روابط بین نوشته‌ها و دسته‌بندی‌ها، مسیرهای عمومی و مدیریتی، فرم‌های ثبت محتوا و بخش‌هایی از مدیریت تصویر و سئو را پیاده‌سازی کردیم.

نکته مهم این است که CMS فقط مجموعه‌ای از فرم‌های ثبت و ویرایش محتوا نیست. امنیت، مجوزهای دسترسی، ساختار مناسب دیتابیس، مدیریت فایل‌ها، سئو و قابلیت توسعه، همگی در کیفیت نهایی سیستم تأثیر دارند.

اگر قصد دارید یک CMS واقعی برای پروژه خود بسازید، پیشنهاد می‌شود ابتدا همین هسته را کامل و تست کنید و سپس امکاناتی مانند مدیریت صفحات ثابت، نقش‌های کاربران، سیستم نظرات، ویرایشگر محتوا و API را به آن اضافه کنید.

منابع رسمی برای مطالعه بیشتر

  • مستندات Laravel 12
  • Eloquent ORM
  • اعتبارسنجی درخواست‌ها
  • احراز هویت
  • مدیریت فایل‌ها
  • تست دیتابیس

برای ادامه یادگیری، مقالات مرتبط با ساختار پروژه Laravel، احراز هویت، اعتبارسنجی، آپلود فایل و بهینه‌سازی Query می‌توانند مسیر مناسبی برای توسعه این CMS باشند.

نظرات (0)

نظرات پس از تأیید مدیر نمایش داده می‌شوند

هنوز نظری ثبت نشده است؛ اولین نفر باشید!

مقالات مرتبط

ساخت فروشگاه اینترنتی با Laravel 12؛ آموزش پروژه‌محور از صفر تا طراحی هسته فروشگاه

ساخت فروشگاه اینترنتی با Laravel 12؛ آموزش پروژه‌محور از صفر تا طراحی هسته فروشگاه

۲۲ دقیقه
ساخت Marketplace با Laravel؛ آموزش طراحی فروشگاه چندفروشندگی

ساخت Marketplace با Laravel؛ آموزش طراحی فروشگاه چندفروشندگی

۲۲ دقیقه
مدیریت Queueهای سنگین در Laravel؛ آموزش پردازش کارهای طولانی

مدیریت Queueهای سنگین در Laravel؛ آموزش پردازش کارهای طولانی

۲۲ دقیقه
مشکل N+1 Query در Laravel چیست؟ آموزش رفع با مثال عملی

مشکل N+1 Query در Laravel چیست؟ آموزش رفع با مثال عملی

۱۷ دقیقه