jonhassall/belongs-to-user-or-guest

Trait for models that can belong to either a user or a guest session

Maintainers

Package info

github.com/jonhassall/BelongsToUserOrGuest

pkg:composer/jonhassall/belongs-to-user-or-guest

Transparency log

Statistics

Installs: 39

Dependents: 0

Suggesters: 0

Stars: 1

Open Issues: 0

v1.0.3 2026-08-13 23:32 UTC

This package is auto-updated.

Last update: 2026-08-14 00:08:05 UTC


README

A Laravel package that provides traits for Eloquent models to support ownership by either authenticated users or guest sessions. This is perfect for applications where you want to allow both logged-in users and guests to interact with your application - for example, shopping carts, wishlists, favorites, or any feature that should work without requiring users to sign up first.

Features

BelongsToUserOrGuest Trait

Attach this trait to any model that can belong to either a logged-in user or a guest session.

Key Features:

  • Automatic Assignment: Automatically assign models to the current user or guest session
  • Ownership Check: Check if a model belongs to the current request
  • Query Scopes: Retrieve only models owned by the current user or guest
  • Guest-to-User Migration: Move guest-owned models to a user account when they log in
  • Flexible Relationships: Support for custom guest model classes and foreign keys

IsGuest Trait

Attach this trait to your guest session model to manage guest sessions with cookie-based tracking.

Key Features:

  • Cookie Handling: Persistent cookies to track guests across visits
  • IP Tracking: Optional IP address storage for security or analytics
  • Binary Token Storage: Optionally store session tokens as compact 32-byte values
  • Session Cleanup: Remove old, expired guest sessions
  • Caching: Request-level caching for performance
  • Configurable: Customizable cookie names, durations, and behaviors

Requirements

  • Laravel: 9.x through 13.x
  • PHP: 8.1 or higher
  • Cookies: This package uses cookies to track guest sessions and does not require Laravel's session driver to be configured. All guest session management is handled entirely through cookies, making it lightweight and independent of your application's session configuration.

Installation

Option 1: Via Packagist (Recommended)

Install the package via Composer:

composer require jonhassall/belongs-to-user-or-guest

Option 2: Install Directly from GitHub

You can install it directly from the GitHub repository. Add this to your composer.json:

{
  "repositories": [
    {
      "type": "vcs",
      "url": "https://github.com/jonhassall/BelongsToUserOrGuest.git"
    }
  ],
  "require": {
    "jonhassall/belongs-to-user-or-guest": "dev-main"
  }
}

Then run:

composer install

Quick Start

Step 1: Set Up Your Guest Model

Create a migration for your guest sessions table:

php artisan make:migration create_guests_table
Schema::create('guests', function (Blueprint $table) {
    $table->id();
    $table->string('session_token', 64)->unique(); // Guest session token (not Laravel session)
    $table->string('ip_address')->nullable(); // Optional
    $table->timestamps();
});

Create your Guest model:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use JonHassall\BelongsToUserOrGuest\IsGuest;

class Guest extends Model
{
    use IsGuest;

    protected $fillable = ['session_token', 'ip_address'];

    // Required: Cookie name for guest sessions
    protected function getCookieName(): string
    {
        return 'guest_session';
    }

    // Optional: Cookie duration in minutes (default: 1 year)
    protected function getCookieDuration(): int
    {
        return 525600; // 1 year
    }

    // Optional: Store guest IP addresses
    protected function shouldStoreIP(): bool
    {
        return true;
    }

    // Optional: Store session tokens as 32 raw bytes rather than hexadecimal text.
    // Requires the binary guest-table schema shown below.
    protected function shouldStoreSessionTokenAsBinary(): bool
    {
        return true;
    }
}

Step 2: Set Up Your Resource Model

Add the necessary columns to your resource table:

Schema::create('favorites', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
    $table->foreignId('guest_id')->nullable()->constrained()->onDelete('cascade');
    $table->string('item_name');
    $table->timestamps();
});

Add the trait to your model:

namespace App\Models;

use Illuminate\Database\Eloquent\Model;
use JonHassall\BelongsToUserOrGuest\BelongsToUserOrGuest;

class Favorite extends Model
{
    use BelongsToUserOrGuest;

    protected $fillable = ['item_name', 'user_id', 'guest_id'];

    // Required: Specify your guest model class
    protected function getGuestModel(): string
    {
        return Guest::class;
    }

    // Optional: Custom foreign key (default: 'guest_id')
    protected function getGuestForeignKey(): string
    {
        return 'guest_id';
    }
}

Usage Examples

Creating Records with Ownership

// Method 1: Create and assign manually
$favorite = new Favorite(['item_name' => 'Laravel']);
$favorite->assignToCurrentOwner(); // Automatically assigns to user or guest

// Method 2: Create with ownership in one step
$favorite = Favorite::createWithOwnership([
    'item_name' => 'Laravel'
]);

// Method 3: Update or create with ownership
$favorite = Favorite::updateOrCreateWithOwnership(
    ['item_name' => 'Laravel'], // Search criteria
    ['updated_at' => now()]      // Additional values
);

Querying Records

// Get all favorites for current user or guest
$favorites = Favorite::forCurrentOwner()->get();

// Check if a specific favorite belongs to current user/guest
if ($favorite->isOwnedByCurrentRequest()) {
    // Allow editing or deletion
}

// Access the owner (returns User or Guest model)
$owner = $favorite->owner;

Migrating Guest Data to User Account

When a guest logs in or signs up, transfer their data:

// Option 1: In your login/registration controller
use App\Models\Favorite;
use App\Models\Guest;

public function login(Request $request)
{
    // After successful authentication...

    // Move all guest favorites to the logged-in user
    Favorite::moveGuestToUser($request);

    // Optionally delete the guest session
    Guest::deleteGuestSession($request);
}
// Option 2: Automatically on login using AppServiceProvider
// In app/Providers/AppServiceProvider.php

use Illuminate\Support\Facades\Event;
use Illuminate\Auth\Events\Login;
use App\Models\Favorite;
use App\Models\ShoppingCart;
use App\Models\Guest;

public function boot(): void
{
    // Automatically move guest data when user logs in
    Event::listen(function (Login $event) {
        // Move guest-owned models to the logged-in user
        Favorite::moveGuestToUser();

        // Delete the guest session after migrating data
        Guest::deleteGuestSession();

        // Optional: Notify the user their data was saved
        if (request()->hasSession()) {
            session()->flash('success', 'Your favorites and cart have been saved to your account.');
        }
    });
}

Working with Guest Sessions Directly

use App\Models\Guest;

// Get or create guest for current request
$guest = Guest::getOrCreateGuest();

// Check if current request is a guest (not logged in)
if (Guest::isGuest()) {
    // Show guest-specific messaging
}

// Get current user or guest
$currentOwner = Guest::getCurrentUserOrGuest();

// Clean up old guest sessions (run in scheduled task)
$deletedCount = Guest::cleanup();

Advanced Usage

// Create without auto-saving
$favorite = new Favorite(['item_name' => 'Vue']);
$favorite->assignToCurrentOwner(persist: false); // Don't save yet
$favorite->some_other_field = 'value';
$favorite->save(); // Save when ready

// Remove ownership
$favorite->removeOwnership(); // Clears both user_id and guest_id

// Query scopes with explicit request
$favorites = Favorite::forCurrentOwner($customRequest)->get();

Method Reference

BelongsToUserOrGuest Methods

Method Description
assignToCurrentOwner(?Request $request, bool $persist = true) Assign model to current user or guest
removeOwnership(bool $persist = true) Clear ownership from model
isOwnedByCurrentRequest(?Request $request) Check if current user/guest owns this model
scopeForCurrentOwner($query, ?Request $request) Query scope for current owner's models
moveGuestToUser(?Request $request) Transfer all guest models to logged-in user
createWithOwnership(array $data, ?Request $request) Create model with ownership in one step
updateOrCreateWithOwnership(array $attributes, array $values, ?Request $request) Update or create with ownership
user() Relationship to User model
guest() Relationship to Guest model
owner Attribute accessor for current owner (User or Guest)

IsGuest Methods

Method Description
getOrCreateGuest(?Request $request, bool $createIfNeeded = true) Get or create guest session
getGuest(?Request $request, bool $createIfNeeded, bool $ignoreLoggedIn) Get guest session with options
getCurrentUserOrGuest(?Request $request, bool $createIfNeeded) Get authenticated user or guest
isGuest(?Request $request) Check if request is from a guest
deleteGuestSession(?Request $request) Delete guest session and cookie
cleanup() Remove expired guest sessions

Configuration

BelongsToUserOrGuest Configuration

Method Description
getGuestModel(): string Return the fully qualified class name of your guest model. (Required)
getGuestForeignKey(): string Foreign-key column name; defaults to 'guest_id'. (Optional)

IsGuest Configuration

Method Description
getCookieName(): string Return the cookie name for guest sessions. (Required)
getCookieDuration(): int Cookie lifetime in minutes; defaults to 525600 (one year). (Optional)
shouldStoreIP(): bool Enable IP address storage; defaults to false. (Optional)
shouldStoreSessionTokenAsBinary(): bool Store session tokens as 32 raw bytes rather than 64-character hexadecimal text; model attributes and cookies remain hexadecimal text. Defaults to false; see the binary schema option below. (Optional)

The package generates a cryptographically secure 64-character hexadecimal token whenever it creates a guest session.

Database Schema Requirements

Resource Table (e.g., favorites)

$table->foreignId('user_id')->nullable()->constrained()->onDelete('cascade');
$table->foreignId('guest_id')->nullable()->constrained('guests')->onDelete('cascade');

Guest Table

$table->id();

// Default: 64-character hexadecimal text storage.
$table->string('session_token', 64)->unique();

// Binary token option for MySQL:
// - Stores 32 raw bytes instead of 64 bytes of hexadecimal text (50% less token storage).
// - The unique index is smaller, which can improve index-cache efficiency for large tables.
// - The model attribute and cookie remain hexadecimal text.
// - Replace the string() line above with this only after legacy guest rows are migrated or expired:
// $table->char('session_token', 32)->charset('binary')->unique(); // BINARY(32)

$table->string('ip_address')->nullable(); // If using shouldStoreIP()
$table->timestamps();

Scheduled Tasks

Add to app/Console/Kernel.php to clean up old guest sessions:

protected function schedule(Schedule $schedule)
{
    $schedule->call(function () {
        \App\Models\Guest::cleanup();
    })->daily();
}

Migrating an Existing MySQL Guest Table to Binary Tokens

Keep shouldStoreSessionTokenAsBinary() returning false while this migration runs. The following reversible Laravel migration preserves existing 64-character hexadecimal tokens by converting them through a temporary binary column. It assumes Laravel's default guests_session_token_unique index name; adjust the table or index names for a custom schema. Always back up your database before migrating!

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

return new class extends Migration
{
    public function up(): void
    {
        // Add a nullable binary column while the legacy text token remains in use.
        Schema::table('guests', function (Blueprint $table): void {
            // MySQL treats CHAR with the binary character set as binary string data.
            $table->char('session_token_binary', 32)->charset('binary')->nullable();
        });

        // Convert every existing hexadecimal token in one database-side update.
        DB::table('guests')->update([
            'session_token_binary' => DB::raw('UNHEX(session_token)'),
        ]);

        // Remove the old text column and its unique index after every row is converted.
        Schema::table('guests', function (Blueprint $table): void {
            $table->dropUnique(['session_token']);
            $table->dropColumn('session_token');
        });

        // Give the converted binary column the package's expected column name.
        Schema::table('guests', function (Blueprint $table): void {
            $table->renameColumn('session_token_binary', 'session_token');
        });

        // Restore the non-null constraint and unique index from the original schema.
        Schema::table('guests', function (Blueprint $table): void {
            $table->char('session_token', 32)->charset('binary')->nullable(false)->change();
            $table->unique('session_token');
        });
    }

    public function down(): void
    {
        // Add a nullable text column while the binary token remains in use.
        Schema::table('guests', function (Blueprint $table): void {
            $table->string('session_token_text', 64)->nullable();
        });

        // Convert every binary token back to lowercase hexadecimal text in one update.
        DB::table('guests')->update([
            'session_token_text' => DB::raw('LOWER(HEX(session_token))'),
        ]);

        // Remove the binary column and its unique index after every row is converted.
        Schema::table('guests', function (Blueprint $table): void {
            $table->dropUnique(['session_token']);
            $table->dropColumn('session_token');
        });

        // Restore the original text column name.
        Schema::table('guests', function (Blueprint $table): void {
            $table->renameColumn('session_token_text', 'session_token');
        });

        // Restore the non-null constraint and unique index from the original schema.
        Schema::table('guests', function (Blueprint $table): void {
            $table->string('session_token', 64)->nullable(false)->change();
            $table->unique('session_token');
        });
    }
};

Enable shouldStoreSessionTokenAsBinary() only after the migration succeeds. Before rolling back, coordinate the rollback with changing that method back to false. Run either direction in a maintenance window so requests never use the text-storage mode against the binary column, or vice versa. The cookie remains hexadecimal text, so converted sessions continue to resolve. Take a database backup first. Laravel 9 may require doctrine/dbal for the column rename or change() operation; PostgreSQL and SQLite need the same temporary-column, backfill, and swap sequence using their native binary type.

Versioning and Branches

  • Stable releases use semantic version tags, for example: v1.0.2
  • The main branch is the primary release branch
  • Composer branch alias maps dev-main to 1.x-dev for users tracking development
  • Patch releases should branch from main, then be tagged and pushed

Development Branches

Develop unreleased work on a development/<feature> branch, validate it there, then merge it into main when it is ready for a public release. Do not tag or publish from a development branch.

git switch main
git pull origin main
git switch -c development/binary-session-token-storage
composer test

Release example:

git checkout main
git pull origin main
git tag -a v1.0.3 -m "Release v1.0.3"
git push origin main --tags

Full release checklist: RELEASE_CHECKLIST.md

Testing

composer install
composer test

License

This package is open-sourced software licensed under the MIT license.