■P301

────────────────
sail composer require laravel/pennant:1.23.0
────────────────

────────────────
sail artisan vendor:publish --provider="Laravel\Pennant\PennantServiceProvider"
────────────────

────────────────
sail artisan migrate
────────────────


■P302

────────────────
sail artisan make:provider PennantServiceProvider
────────────────

●app/Providers/PennantServiceProvider.php
────────────────
<?php

namespace App\Providers;

use Illuminate\Support\ServiceProvider;
use Laravel\Pennant\Feature;

class PennantServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        //
    }

    public function boot(): void
    {
        // Pennantが参照するスコープを「常にnull（グローバル）」へ固定
        // <https://laravel.com/docs/13.x/pennant#default-scope>
        Feature::resolveScopeUsing(fn ($driver) => null);

        // デフォルトは常にOFF。`Feature::activate()` などで値を保存すると
        // 以降は保存済みの値が優先される（Pennantがfeaturesテーブルを参照）。
        Feature::define('image.upload', fn () => false);
    }
}
────────────────


■P303

●bootstrap/providers.php
────────────────
return [
    App\Providers\AppServiceProvider::class,
    App\Providers\PennantServiceProvider::class,
];
────────────────

────────────────
use Laravel\Pennant\Attributes\Name;

#[Name('image.upload')]
class ImageUpload
{    
		public function resolve(mixed $scope): bool
		{
				return false;
		}
}
────────────────


■P305

●app/Http/Controllers/Tweet/IndexController.php
────────────────
use App\Services\TweetService;
use Illuminate\Http\Request;

class IndexController
{
    public function __invoke(Request $request, TweetService $tweetService)
    {
        $tweets = $tweetService->getTweets();

        return view('tweet.index', [
            'tweets' => $tweets,
        ]);
    }
}
────────────────

●resources/views/components/tweet/form/post.blade.php
────────────────
@auth
    <div class="p-4">
        <form action="{{ route('tweet.create') }}" method="post" enctype="multipart/form-data">
            @csrf
            <x-form.textarea name="tweet" rows="3" placeholder="つぶやきを入力" helpText="140文字まで" required />

            @feature('image.upload')
                <x-tweet.form.images/>
            @else
                <p class="text-sm text-gray-500 mt-2">
                    画像アップロード機能は準備中です。次の章で公開方法を確認しましょう。
                </p>
            @endfeature

            <div class="flex flex-wrap justify-end mt-4">
                <x-element.button>つぶやく</x-element.button>
            </div>
        </form>
    </div>
@endauth
────────────────


■P306

●resources/views/tweet/index.blade.php
────────────────
<x-tweet.form.post></x-tweet.form.post>
<x-tweet.list :tweets="$tweets"></x-tweet.list>
────────────────


■P307

●resources/views/components/tweet/list.blade.php（全体）
────────────────
@props([
    'tweets' => []
])
<div class="bg-white rounded-md shadow-lg mt-5 mb-5">
    <ul>
        @foreach($tweets as $tweet)
            <li class="border-b last:border-b-0 border-gray-200 p-4 flex items-start justify-between">
                <div>
                    <span class="inline-block rounded-full text-gray-600 bg-gray-100 px-2 py-1 text-xs mb-2">
                        {{ $tweet->user->name }}
                    </span>
                    <p class="text-gray-600">{!! nl2br(e($tweet->content)) !!}</p>
                    @feature('image.upload')
                        <x-tweet.images :images="$tweet->images"/>
                    @endfeature
                </div>
                <div>
                    <x-tweet.options :tweetId="$tweet->id" :userId="$tweet->user_id"></x-tweet.options>
                </div>
            </li>
        @endforeach
    </ul>
</div>
────────────────


■P308

●app/Http/Requests/Tweet/CreateRequest.php
────────────────
use Laravel\Pennant\Feature;

…省略…

    public function rules(): array
    {
        $rules = [
            'tweet' => 'required|max:140',
        ];

        if (Feature::active('image.upload')) {
            $rules['images'] = 'array|max:4';
            $rules['images.*'] = 'required|image|mimes:jpeg,png,jpg,gif|max:2048';
        }

        return $rules;
    }
────────────────

●app/Services/TweetService.php
────────────────
use Laravel\Pennant\Feature;

…省略…

    public function saveTweet(int $userId, string $content, array $images): void
    {
        DB::transaction(function () use ($userId, $content, $images) {
            $tweet = new Tweet;
            $tweet->user_id = $userId;
            $tweet->content = $content;
            $tweet->save();

            if (!Feature::active('image.upload')) {
                return;
            }
            
            /** @var UploadedFile $image */
            foreach ($images as $image) {
                Storage::disk('public')->putFile('images', $image);

                $imageModel = new Image;
                $imageModel->name = $image->hashName();
                $imageModel->save();
                $tweet->images()->attach($imageModel->id);
            }
        });
    }
────────────────


■P309

────────────────
sail artisan make:test Tweet/CreateTest
────────────────

●tests/Feature/Tweet/CreateTest.php
────────────────
<?php

namespace Tests\Feature\Tweet;

use App\Models\Tweet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Laravel\Pennant\Feature;
use Tests\TestCase;

class CreateTest extends TestCase
{
    use RefreshDatabase;

    private const FLAG = 'image.upload';

    public function test_画像アップロードが有効なユーザーはファイルを投稿できる(): void
    {
        Storage::fake('public');

        $user = User::factory()->create();
        Feature::activate(self::FLAG);

        $response = $this->actingAs($user)->post(route('tweet.create'), [
            'tweet' => '画像付き投稿です',
            'images' => [
                UploadedFile::fake()->image('example.jpg'),
            ],
        ]);

        $response->assertRedirect(route('tweet.index'));

        $tweet = Tweet::with('images')->latest()->first();

        $this->assertNotNull($tweet);
        $this->assertCount(1, $tweet->images);

        $imageName = $tweet->images->first()->name;

        Storage::disk('public')->assertExists('images/'.$imageName);
    }

    public function test_フラグが無効な場合は画像が保存されない(): void
    {
        Storage::fake('public');

        $user = User::factory()->create();
        Feature::deactivate(self::FLAG);

        $response = $this->actingAs($user)->post(route('tweet.create'), [
            'tweet' => 'テキストのみの投稿です',
            'images' => [
                UploadedFile::fake()->image('should-not-save.jpg'),
            ],
        ]);

        $response->assertRedirect(route('tweet.index'));

        $tweet = Tweet::with('images')->latest()->first();

        $this->assertNotNull($tweet);
        $this->assertCount(0, $tweet->images);

        $this->assertEmpty(Storage::disk('public')->allFiles());
    }
}
────────────────


■P311

────────────────
sail test tests/Feature/Tweet/CreateTest.php
────────────────

────────────────
sail artisan make:test Tweet/IndexTest
────────────────


■P312

●tests/Feature/Tweet/IndexTest.php
────────────────
<?php

namespace Tests\Feature\Tweet;

use App\Models\Image;
use App\Models\Tweet;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Pennant\Feature;
use Tests\TestCase;

class IndexTest extends TestCase
{
    use RefreshDatabase;

    private const FLAG = 'image.upload';
    
    protected function setUp(): void
    {
        parent::setUp();
        $this->withoutVite();
    }

    public function test_グローバルフラグが有効ならゲストも画像を閲覧できる(): void
    {
        $this->prepareFlags();
        $image = $this->prepareTweetWithImage();
        Feature::activate(self::FLAG);
        $response = $this->get(route('tweet.index'));

        $response->assertStatus(200)
            ->assertSee('storage/images/'.$image->name);
    }

    public function test_グローバルフラグが無効ならゲストには画像が表示されない(): void
    {
        $this->prepareFlags();
        $image = $this->prepareTweetWithImage();
        Feature::deactivate(self::FLAG);
        $response = $this->get(route('tweet.index'));
        $response->assertStatus(200)
            ->assertDontSee('storage/images/'.$image->name);
    }

    private function prepareTweetWithImage(): Image
    {
        $user = User::factory()->create();
        $tweet = Tweet::factory()->create(['user_id' => $user->id]);
        $image = Image::factory()->create();
        $tweet->images()->attach($image->id);

        return $image;
    }

    private function prepareFlags(): void
    {
        Feature::purge(self::FLAG);
        Feature::flushCache();
    }
}
────────────────


■P313

────────────────
sail test tests/Feature/Tweet/IndexTest.php
────────────────


■P314

────────────────
sail artisan feature:toggle image.upload --on
────────────────

────────────────
sail artisan feature:toggle image.upload --off
────────────────

────────────────
sail artisan make:command FeatureToggleCommand
────────────────

●app/Console/Commands/FeatureToggleCommand.php
────────────────
<?php

namespace App\Console\Commands;

use Illuminate\Console\Attributes\Description;
use Illuminate\Console\Attributes\Signature;
use Illuminate\Console\Command;
use Laravel\Pennant\Feature;

#[Signature('feature:toggle {name : フィーチャーフラグの名前} {--on : フラグを有効化します} {--off : フラグを無効化します}')]
#[Description('フィーチャーフラグを切り替えます。')]
class FeatureToggleCommand extends Command
{
    /**
     * Execute the console command.
     */
    public function handle(): int
    {
        $name = $this->argument('name');
        $turnOn = $this->option('on');
        $turnOff = $this->option('off');
        if (($turnOn && $turnOff) || (! $turnOn && ! $turnOff)) {
            $this->error('オプションには --on か --off のいずれか一方を指定してください。');
            return self::FAILURE;
        }

        $allFeatures = Feature::all();
        $featureExists = in_array($name, $allFeatures, true) || array_key_exists($name, $allFeatures);
        if (! $featureExists) {
            $this->error("Feature [{$name}] は定義されていません。");
            return self::FAILURE;
        }

        $messageSuffix = $turnOn ? 'on' : 'off';

        if ($turnOn) {
            Feature::activate($name);
            Feature::activateForEveryone($name);
        } else {
            Feature::deactivateForEveryone($name);
            Feature::deactivate($name);
        }

        // フラグはキャッシュされるので、ここで揮発させる
        Feature::flushCache();

        $this->info("Feature [{$name}] を {$messageSuffix} に切り替えました。");

        return self::SUCCESS;
    }
}
────────────────


■P316

────────────────
sail artisan feature:toggle image.upload --on
────────────────

────────────────
sail artisan make:test Console/FeatureToggleCommandTest
────────────────

●tests/Feature/Console/FeatureToggleCommandTest.php
────────────────
<?php

namespace Tests\Feature\Console;

use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Pennant\Feature;
use Tests\TestCase;

class FeatureToggleCommandTest extends TestCase
{
    use RefreshDatabase;

    private const FLAG = 'image.upload';

    public function test_全ユーザーを対象に有効化と無効化が行える(): void
    {
        Feature::purge(self::FLAG);

        $this->artisan('feature:toggle '.self::FLAG.' --on')
            ->expectsOutput('Feature ['.self::FLAG.'] を on に切り替えました。')
            ->assertExitCode(0);

        $this->assertTrue(Feature::active(self::FLAG));

        $this->artisan('feature:toggle '.self::FLAG.' --off')
            ->expectsOutput('Feature ['.self::FLAG.'] を off に切り替えました。')
            ->assertExitCode(0);

        $this->assertFalse(Feature::active(self::FLAG));
    }

    public function test_onとoffを同時に指定するとエラーになる(): void
    {
        $this->artisan('feature:toggle '.self::FLAG.' --on --off')
            ->expectsOutput('オプションには --on か --off のいずれか一方を指定してください。')
            ->assertExitCode(1);
    }

    public function test_onかoffを指定しない場合エラーになる(): void
    {
        $this->artisan('feature:toggle '.self::FLAG)
            ->expectsOutput('オプションには --on か --off のいずれか一方を指定してください。')
            ->assertExitCode(1);
    }
}
────────────────


■P318

────────────────
sail test tests/Feature/Console/FeatureToggleCommandTest.php
────────────────
