環境情報
| 項目 | 内容 |
|---|---|
| PHP | 8.2 |
| Laravel | 10.x |
| encore/laravel-admin | 1.8.x |
| MySQL | 8.0 |
| フロント | Blade + AdminLTE 3 |
| 画面数 / データ量 | 管理画面 約40画面 / 会員 約12,000件 |
| 状況 | 仕様書なし・前任者退職済み・テストコードなし |
何が起きたか
某会員管理システムの改修中、クライアントからこんな提案が飛んできました。
「このボタン、役割が分からないので非表示でいいのでは?」
対象は、会員一覧の行アクションに並ぶラベルだけの小さなボタンでした。押すと一覧が再描画されるだけで、画面上はほとんど何も変わりません。仕様書はなく、前任者は不在。即答は危険だと判断して「一度持ち帰ります」と保留しました。
結論から言うと、このボタンを非表示にすると、完全削除機能そのものが使えなくなるという危険な提案でした。コードを追った結果、当該ボタンは「ソフト無効化」を実行するもので、完全削除ボタンを表示させる唯一の導線だったのです。
なぜ「見た目」で判断してはいけないのか
管理画面のUIは、フレームワークが提供する描画ロジックの上に乗っています。laravel-admin の場合、行アクションは次のように条件分岐で出し分けられます。
// app/Admin/Controllers/MemberController.php
$grid->actions(function ($actions) {
$model = $actions->row;
if (is_null($model->disabled_at)) {
// こちらが「役割不明」と言われたボタン
$actions->append(new SoftDisableAction());
} else {
// disabled_at が入っている行にしか描画されない
$actions->append(new ForceDeleteAction());
}
});
つまり ForceDeleteAction は、disabled_at が埋まっている行にしか存在しません。そして disabled_at を埋められるのは SoftDisableAction だけ。ボタンを消す = 状態遷移の入口を塞ぐ = 後続のボタンが永遠に現れない、という構造でした。
// app/Admin/Actions/Grid/SoftDisableAction.php
namespace App\Admin\Actions\Grid;
use Encore\Admin\Actions\RowAction;
use Illuminate\Database\Eloquent\Model;
class SoftDisableAction extends RowAction
{
public $name = '無効化';
public function handle(Model $model)
{
$model->disabled_at = now();
$model->save();
return $this->response()
->success('無効化しました。再読み込みすると完全削除が選べます。')
->refresh();
}
}
// app/Admin/Actions/Grid/ForceDeleteAction.php
class ForceDeleteAction extends RowAction
{
public $name = '完全削除';
public function handle(Model $model)
{
$model->forceDelete(); // SoftDeletes を貫通する
return $this->response()->success('完全削除しました。')->refresh();
}
}
調査手順(実際にやった5ステップ)
- ルーティングをgrepしてエンドポイントを特定する
- 表示中HTMLから
data-url/form actionを拾い、遷移先を確定する - コントローラ → RowAction → Model → migration の順に追う
- DBの実データで「押された形跡」を確認する
- 他画面からの遷移・権限・バッチなど依存関係を洗う
Step 1: ルーティングを探す
grep -rn "member" routes/admin.php
php artisan route:list | grep -i member
Step 4: データで裏を取る
ここが一番効きました。「使われていないのでは」という仮説を、事実で潰します。
SELECT
COUNT(*) AS total,
COUNT(disabled_at) AS disabled_cnt,
SUM(CASE WHEN disabled_at IS NOT NULL AND deleted_at IS NOT NULL THEN 1 ELSE 0 END) AS force_deleted_cnt
FROM members;
結果は total 12,480 / disabled_cnt 1,203 / force_deleted_cnt 87。完全削除された87件は、すべて無効化を経由していることが確認できました。非表示にすれば、この運用フローが丸ごと止まります。
非表示にした場合の比較
| 観点 | 非表示にする | 残す |
|---|---|---|
| 完全削除 | 到達不能になる | 従来どおり可能 |
| 誤操作リスク | 下がる | 変わらない(要改善) |
| 法令対応(削除請求) | 対応手段が消える | 維持できる |
| 改修コスト | 小 | 小(ラベル改善のみ) |
| 将来の復旧 | 気づくまで誰も困らない | 影響なし |
ハマりポイント
-
$grid->disableDelete()と$actions->disableDelete()は別物。前者は一括削除、後者は行アクション。混同すると「消したつもりで消えていない」 -
$actions->rowでリレーションを触るとN+1が発生する。with()を忘れずに - カスタムアクションはPOSTで飛ぶ。CSRF・ミドルウェア・権限(
Admin::user()->can())を確認する - Blade側に
@canで隠しているケースもあり、PHPだけ読むと見落とす - 設定キャッシュ(
php artisan config:clear)を忘れて「反映されない」とハマる
最終的に提案した2ステップ運用ルール
- 無効化 → 30日後に完全削除(誤操作の取り消し猶予を確保)
- 完全削除は管理者2名の承認を必須にする
ボタンは残し、ラベルを「無効化(→30日後に削除可能)」に変更して意味を明示しました。
FAQ
Q. 使われていないボタンは消すべきでは?
A. 「使われていない」をデータで証明できてからです。updated_at やフラグ列の分布を見れば数分で検証できます。
Q. 仕様書がない場合はどうする?
A. ルーティング → コントローラ → アクション → モデル → migration の順に上から下へ追うのが最短です。
Q. 最短で“重要度”を見極める方法は?
A. 「そのボタンを消したとき、他のボタンやバッチが到達不能にならないか」を確認する。状態遷移の入口になっているボタンは消してはいけません。
まとめ
UIの見た目は、機能の要否を判断する材料になりません。特に管理画面の行アクションは、条件分岐で出し分けられる状態遷移の一部です。提案を受けたら、まずコードとデータを読み、影響範囲を確定させてから答える。これだけで「便利だから消した」という事故をかなり防げます。
この記事を書いた人
BENTEN Web Works — 業務自動化・システム開発のフリーランスエンジニアです。
GAS / Python / RPA を使った業務自動化や、Web制作・システム開発のご相談を承っています。
「こんなこと自動化できる?」というご質問だけでもお気軽にどうぞ。
👉 BENTEN Web Works — 詳細・お問い合わせはこちら
🐦 X(旧Twitter) — 日々の知見を発信中