ka4ivan/laravel-model-releases

Model Releases (versions) for Laravel Framework

2.0.1 2025-03-02 18:19 UTC

This package is auto-updated.

Last update: 2025-03-06 19:54:57 UTC


README

License Build Status Latest Stable Version Total Downloads

📖 Table of Contents

Installation

  1. Require this package with composer
composer require ka4ivan/laravel-model-releases
  1. Publish package resource:
php artisan vendor:publish --provider="Ka4ivan\ModelReleases\ServiceProvider"
  • config
  • migration

This is the default content of the config file:

<?php

return [
    /**
     * Models with relations for which slugs will be created using the command
     */
    'models' => [
//        \App\Models\Post::class => [
//            'relations' => [
//                'media',
//                'translations',
//            ],
//        ],
//        \App\Models\Translations\PostTranslation::class => [],
//        \App\Models\Media::class => [],
    ],

    /**
     * Release model
     */
    'model' => \Ka4ivan\ModelReleases\Models\Release::class,

    /**
     * Number of days after which release data will be considered stale and will be purged.
     *
     * If the number of days is 0, data will not be purged.
     *
     * It is impossible to rollback to a purged release!
     */
    'cleanup' => [
        'outdated_releases_for_days' => 30,
    ],
];
  1. Run migration:
php artisan migrate

Usage

Preparing your model

To associate releases with a model, the model must implement the following traits: HasReleases, SoftDeletes.

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Ka4ivan\ModelReleases\Models\Traits\HasReleases;

class Article extends Model
{
    use HasUuids,
        SoftDeletes, // REQUIRED!!!
        HasReleases;
}

Preparing your migration

If this is one migration.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::create('articles', function (Blueprint $table) {
            $table->uuid('id')->primary();
            $table->string('slug')->nullable()->index();
            $table->string('name')->nullable();
            $table->longText('body')->nullable();
            $table->string('status')->default(true);
            $table->timestamps();
            
            $table->softDeletes();
            $table->releaseFields();
//            $table->releaseUuidFields(); if the `id` field is a uuid

            $table->uuid('category_id')->nullable()->index();
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::dropIfExists('articles');
    }
};

If this is an additional migration.

<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    /**
     * Run the migrations.
     */
    public function up(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            $table->softDeletes(); // If it wasn't there before
            $table->releaseFields();
//            $table->releaseUuidFields(); If the id field is a uuid
        });
    }

    /**
     * Reverse the migrations.
     */
    public function down(): void
    {
        Schema::table('articles', function (Blueprint $table) {
            $table->dropColumn('deleted_at'); // If it wasn't there before
            $table->dropReleaseFields();
        });
};

Here's what they actually add.

Blueprint::macro('releaseUuidFields', function () {
    /** @var Blueprint $this */
    $this->timestamp('archive_at')->nullable()->after('deleted_at');
    $this->json('release_data')->nullable();
    $this->foreignUuid('release_id')->nullable()->constrained('releases')->onDelete('set null');
    $this->uuid('prerelease_id')->nullable();
});

Blueprint::macro('releaseFields', function () {
    /** @var Blueprint $this */
    $this->timestamp('archive_at')->nullable()->after('deleted_at');
    $this->json('release_data')->nullable();
    $this->foreignUuid('release_id')->nullable()->constrained('releases')->onDelete('set null');
    $this->unsignedBigInteger('prerelease_id')->nullable();
});

Blueprint::macro('dropReleaseFields', function () {
    /** @var Blueprint $this */
    $this->dropForeign(['release_id']);
    $this->dropColumn(['release_id', 'prerelease_id', 'release_data', 'archive_at']);
});

Base model usage

Store model

/**
 * @param ArticleRequest $request
 * @return \Illuminate\Http\RedirectResponse
 */
public function store(ArticleRequest $request)
{
    $data = $request->getData();

    /** @var Article $article */
    $article = Article::create($data);

    $article->sync($request->get('terms', []), [$article->category_id]);
    $article->mediaManage($request);

    return redirect()->route('admin.articles.index')
        ->with('success', trans('alerts.store.success'));
}

Update model

/**
 * @param ArticleRequest $request
 * @param $id
 * @return \Illuminate\Http\RedirectResponse
 */
public function update(ArticleRequest $request, Article $article)
{
    $data = $request->getData();

    $article = $article->updateWithReleases($data);

    $article->sync($request->get('terms', []), [$article->category_id]);
    $article->mediaManage($request);

    return redirect()->route('admin.articles.index')
        ->with('success', trans('alerts.update.success'));
}

Delete model

/**
 * @param Article $article
 * @return \Illuminate\Http\RedirectResponse
 */
public function destroy(Article $article)
{
    $article->deleteWithReleases();

    return redirect()->back()
        ->with('success', trans('alerts.destroy.success'));
}

Scopes

// Client
$posts = Post::with('media','translations', 'categories.translations', 'category.translations')
    ->byReleased()
    ->paginate();

// Admin
$posts = Post::query()
    ->with('translations', 'category', 'prerelease.translations')
    ->whereDoesntHave('origin') // Optional
    ->byAdminReleased()
    ->paginate();

Base relationships

/**
 * Release to which this model belongs
 *
 * @return BelongsTo
 */
public function release(): BelongsTo
{
    return $this->BelongsTo(\ModelRelease::getReleaseModel());
}

/**
 * A model that is not yet in release and is a draft of a model in release
 *
 * @return HasOne
 */
public function prerelease(): HasOne
{
    return $this->hasOne(self::class, 'id', 'prerelease_id');
}

/**
 * A model that is already fully released
 *
 * @return HasOne
 */
public function postrelease(): HasOne
{
    return $this->hasOne(self::class, 'prerelease_id', 'id')->whereIn('release_id', \ModelRelease::getActiveReleasesIds());
}

/**
 * The model that is in the release and is the original of the model that is not in the release
 *
 * @return BelongsTo
 */
public function origin(): BelongsTo
{
    return $this->belongsTo(self::class, 'id', 'prerelease_id');
}

Release/Model Changelogs

Release Changelog

$release = Release::first();

$changelog = $release->changelog();

//    $changelog = [
//      'deleted' => [
//        'article' => [
//          0 => '9e39f921-de23-4048-9fe8-08844869a40b'
//        ]
//      ]
//      'updated' => [
//        'article' => [
//          0 => '9e39f941-6c07-4ee0-bbab-b883b2bd1219'
//        ]
//        'page' => [
//          0 => '9e39f921-e810-4272-83f6-dac8bb11f8eb'
//          1 => '9e39f941-716e-4f66-a8ff-8ecdee8903b4'
//        ]
//      ]
//      'created' => [
//        'article' => [
//          0 => '9e39f928-1c62-4b70-8c6b-2d370859296a'
//        ]
//        'page' => [
//          0 => '9e39f928-1e1b-4c20-939b-d7e30216f660'
//        ]
//      ]
//    ]

OR only one model.

$release = Release::first();

$changelog = $release->changelog(Article::class);

//    $changelog = [
//      'deleted' => [
//        0 => '9e39f921-de23-4048-9fe8-08844869a40b'
//      ]
//      'updated' => [
//        0 => '9e39f941-6c07-4ee0-bbab-b883b2bd1219'
//      ]
//      'created' => [
//        0 => '9e39f928-1c62-4b70-8c6b-2d370859296a'
//      ]
//    ]

Release Model Changelog

$article = Article::first();

$changelog = $article->changelog();

//    $changelog = [
//      '9e39f91c-6224-461c-996a-b73892cb9875' => [ // Release ID
//        'created' => App\Models\Post {â–¶}         // Model and action in this release
//      ]
//      '9e39f976-5bdc-4eb9-ad30-2fd3940461b2' => [
//          'updated' => App\Models\Post {â–¶}
//      ]
//      '9e39f9a4-bcae-42e2-b9eb-b4969308ed32' => [
//          'updated' => App\Models\Post {â–¶}
//      ]
//      'prerelease' => [
//          'deleted' => App\Models\Post {â–¶}
//      ]
//    ]

Run/Rollback Releases

Run release

$res = \ModelRelease::runRelease($data);

//    $res = [
//        'status' => 'success',
//        'message' => 'The release was successfully created!',
//    ];
//        OR
//    $res = [
//        'status' => 'error',
//        'message' => 'The release failed: ' . $e->getMessage(),
//    ]; 

Rollback release

WARNING! When performing the operation, all unsaved drafts will be deleted!

$res = \ModelRelease::rollbackRelease();

//    $res = [
//        'status' => 'success',
//        'message' => 'The last release was rollbacked!',
//    ];
//        OR
//    $res = [
//        'status' => 'warning',
//        'message' => 'No release available!',
//    ];
//        OR
//    $res = [
//        'status' => 'error',
//        'message' => 'Rollback failed: ' . $e->getMessage(),
//    ];

Switch release

It is possible to switch to a release that was several steps back or forward and start a new branch of releases.

WARNING! When performing the operation, all unsaved drafts will be deleted!

$release = Release::first();
$res = \ModelRelease::switchRelease($release);

//    $res = [
//        'status' => 'success',
//        'message' => 'The release was successfully switched!',
//    ];
//        OR
//    $res = [
//        'status' => 'error',
//        'message' => 'The release switching failed: ' . $e->getMessage(),
//    ];
//        OR
//    $res = [
//        'status' => 'error',
//        'message' => 'This release is not available for switching!',
//    ];

Clean data

Clean outdated release data

To clean up outdated release data, you can use the command

php artisan release:clean

This command can be run periodically in the cron

$schedule->command('release:clean')
    ->daily()
    ->runInBackground();
  • The number of days after which data is considered outdated can be specified in the config file config('model-releases.cleanup.outdated_releases_for_days')

Clear all Prereleases

Clears all data that is not in the release

$res = \ModelRelease::clearPrereleases()

//    $res = [
//        'status' => 'success',
//        'message' => 'Prereleases was successfully cleared!',
//    ]

Helpers

buildReleaseTree

Returns a collection of releases with a built tree of childrens relationship.

$releases = \Ka4ivan\ModelReleases\Models\Release::all();
$res = buildReleaseTree($releases);