Linux 解决 Laravel 命令行与 Web 进程用户不一致的权限问题

在 Ubuntu 环境中,日常部署代码、执行 Composer 与 Artisan 命令,一般使用 ubuntu 普通用户;
而 Nginx、PHP-FPM 等 Web 服务默认以低权限用户 www-data 运行。

CLI 命令行用户与 Web 运行用户不一致,会直接造成 storagebootstrap/cache 目录读写拒绝、缓存生成失败、日志写入异常等权限问题。

一、统一用户方案

通过让 Web 与命令行共用同一运行用户,从根源规避权限冲突,分为两种实现方式:

1. Web 进程统一使用 ubuntu 用户

修改 PHP-FPM 配置,将进程执行用户改为 ubuntu
操作简单、彻底杜绝权限报错,日常维护最省心。

缺点是会扩大 Web 进程权限范围,存在一定安全隐患。
适合场景:单机单站、个人项目、自用服务、开发/自建生产环境。

2. 命令行统一使用 www-data 用户

保持 PHP-FPM 默认 www-data 隔离权限,执行 Laravel 相关命令时,手动切换为该用户运行。

示例:

1
sudo -u www-data php artisan view:cache

优势是保留系统原生安全隔离,适配企业生产、多站点服务器;
劣势为每次执行 Artisan、Composer 都需要追加用户切换命令,操作繁琐且容易遗漏。

二、附属组 + SGID 标准隔离方案(生产推荐)

该方案是 Laravel 生态公认最佳实践,兼顾安全性与运维便捷性,也是线上生产环境的主流配置。

核心思路: 不改动 Web 服务默认运行用户,通过附属组授权 + SGID 目录继承权限,让两个不同用户互相读写项目临时目录。

  1. ubuntu 追加加入 www-data 附属组,保留原有用户组权限不变;

务必使用 usermod -aG 追加模式,**不可省略 -a**,避免覆盖原有附属组导致权限丢失。

  1. 对读写高频目录绑定归属组,并配置 SGID 权限,使新文件自动继承目录属组,永久解决文件属主错乱问题。

执行命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 追加用户至 www-data 组
sudo usermod -aG www-data ubuntu

# 关键读写目录统一归属 www-data
sudo chown -R www-data:www-data storage bootstrap/cache


# 给文件(及目录) 664 权限
sudo chmod -R 664 storage/* bootstrap/cache/*

# 递归给目录 775 权限
sudo find storage -type d -exec chmod 775 {} \;
sudo find bootstrap/cache -type d -exec chmod 775 {} \;

# 配置 SGID,新建文件/目录自动继承所属组
sudo chmod -R g+s storage bootstrap/cache


# 退出 ubuntu 用户后重新登录,即可生效

这样,名ubuntu用户执行 Artisan 命令时,在 storage 或 bootstrap/cache 目录生成的文件用户为 ubuntu:www-data,php-fpm 生成的文件用户为 www-data:www-data,两者都可以对项目临时目录进行读写操作。

拓展:若业务安全要求极高,可搭配 Ubuntu ACL 精细化授权,进一步收紧权限范围,本文不做展开。

Laravel 新旧版本模型属性访问器、修改器的选用

Laravel 9 之前模型属性访问器、修改器

通过 setFooBarAttribute($value)getFooBarAttribute() 方法来定义和访问自定义动态 fooBar 属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class User extends Model
{
/**
* 设置用户的名字
*/
public function setFirstNameAttribute($value)
{
$this->attributes['first_name'] = strtolower($value);
}

/**
* 获取用户的全名
*/
public function getFullNameAttribute()
{
return "{$this->first_name} {$this->last_name}";
}
}

Laravel 9 及之后版本的模型属性访问器、修改器

通过返回值类型为 Attribute 类来定义和访问自定义动态属性。

阅读更多

Laravel 多租户组件 spatie/laravel-multitenancy 指南

spatie/laravel-multitenancy 多租户组件,它可以帮助你快速实现 Laravel 多租户应用。比 tenancy/tenancy 更灵活。

数据库支持单库模式和1中心库+多租户库模式。多租户库模式下,用户表和用户tokens表可选择放在中心库,也可以放在租户库。

安装

1
2
3
4
5
composer require spatie/laravel-multitenancy
# 发布配置文件,生成 config/multitenancy.php 配置文件
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-config"
# 在 database/migrations/landlord 目录下发布迁移文件,用于创建 tenants 表
php artisan vendor:publish --provider="Spatie\Multitenancy\MultitenancyServiceProvider" --tag="multitenancy-migrations"
1
2
3
4
5
6
7
Schema::create('tenants', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('domain')->unique(); // 用于外部传入id以切换租户,可以改成自己需要的
$table->string('database')->unique(); // 租户数据库
$table->timestamps();
});

配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
// config/multitenancy.php
return [
/*
* 这个类负责确定哪个租户应该是给定请求的最新租户。
* 必须继承 `Spatie\Multitenancy\TenantFinder\TenantFinder`
*/
'tenant_finder' => \App\Multitenancy\TenantFinder\HeaderTenantFinder::class,

/*
* 这些字段由`tender:artist`命令用于匹配一个或多个租户。
*/
'tenant_artisan_search_fields' => [
'id',
'tenant_code', // 默认是domain,根据自己需要修改
'database'
],

/*
* 这些任务将在切换租户时执行。
*
* 有效的任务是实现 `Spatie\Multitenance\Tasks\SwitchTenantTask` 的任何类
*/
'switch_tenant_tasks' => [
\Spatie\Multitenancy\Tasks\PrefixCacheTask::class,
\Spatie\Multitenancy\Tasks\SwitchTenantDatabaseTask::class,
\Spatie\Multitenancy\Tasks\SwitchRouteCacheTask::class,
],

/*
* 这个类是用于在租户上存储配置的模型。
*
* 必须是或继承 `Spatie\Multitenancy\Models\Tenant::class`
*/
'tenant_model' => \App\Models\Landlord\Tenant::class,

/*
* 如果在分派作业时有当前租户,则会在作业上自动设置当前租户的id。
* 执行作业时,作业上设置的租户将变为当前租户。
*/
'queues_are_tenant_aware_by_default' => true,

/*
* 用于连接租户数据库的连接名称。
*
* 如果设置为 `null`,则使用默认的数据库连接。
*/
'tenant_database_connection_name' => 'tenant',

/*
* 用于连接主数据库(Landlord 数据库)的连接名称。
*/
'landlord_database_connection_name' => 'landlord',

/*
* 此键名用于在 Laravel 服务容器(Container)中绑定当前租户实例。
*/
'current_tenant_container_key' => 'currentTenant',

/**
* 如果你希望使用 `SwitchRouteCacheTask` 将租户的路由缓存
* 到一个共享文件中,请将其设置为 `true`。
*/
'shared_routes_cache' => false,

/*
* 你可以通过自定义 Action(动作类)来定制该包的部分行为。
* 你的自定义 Action 必须继承自默认的 Action 类。
*/
'actions' => [
'make_tenant_current_action' => MakeTenantCurrentAction::class,
'forget_current_tenant_action' => ForgetCurrentTenantAction::class,
'make_queue_tenant_aware_action' => MakeQueueTenantAwareAction::class,
'migrate_tenant' => MigrateTenantAction::class,
],

/*
* 你可以自定义该包将队列任务(Queueable)解析为具体 Job 的方式。
*
* 例如,使用 laravel-actions 包(作者:Loris Leiva)时,
* 你可以将 JobDecorator 解析为 getAction(),配置如下:
* JobDecorator::class => 'getAction'
*/
'queueable_to_job' => [
SendQueuedMailable::class => 'mailable',
SendQueuedNotifications::class => 'notification',
CallQueuedClosure::class => 'closure',
CallQueuedListener::class => 'class',
BroadcastEvent::class => 'event',
],

/*
* 即使以下 Job 没有实现 TenantAware 接口,也会自动使其具备租户感知能力。
*/
'tenant_aware_jobs' => [
// ...
],

/*
* 即使以下 Job 没有实现 NotTenantAware 接口,也会强制使其失去租户感知能力。
*/
'not_tenant_aware_jobs' => [
// ...
],
];

自定义租户查找器

通过 X-Tenant-ID header 来确定当前租户。
在 config/multitenancy.php 中配置 tenant_finder 为这个类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<?php
namespace App\Multitenancy\TenantFinder;

use App\Models\Landlord\Tenant;
use Illuminate\Http\Request;
use Spatie\Multitenancy\TenantFinder\TenantFinder;

class HeaderTenantFinder extends TenantFinder
{
/**
* 3.x 签名:必须返回 Tenant 实例
*/
public function findForRequest(Request $request): \App\Models\Landlord\Tenant|null
{
// 这里可以换成子域名
$id = (int)trim($request->headers->get('X-Tenant-ID', ''));

if ($id === 0 || (!$tenant = Tenant::find($id))) {
abort(400, '无效的商户ID');
}

return $tenant;
}
}

模型集成

  • Spatie\Multitenancy\Models\Tenant 类有切换租户的能力。
  • Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection 自动把模型数据库连接切换到中心库。
  • Spatie\Multitenancy\Models\Concerns\UsesTenantConnection 自动把模型数据库连接切换到租户库。

租户分配模型通过继承 Spatie\Multitenancy\Models\Tenant 类,使用 Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection Trait来集成。
中心库模型使用 Spatie\Multitenancy\Models\Concerns\UsesLandlordConnection Trait 集成。
租户模型使用 Spatie\Multitenancy\Models\Concerns\UsesTenantConnection Trait 集成。

授权

App\Http\Kernel::$middlewareGroups 中添加:

1
2
3
4
5
6
7

'tenant' => [
// 获取当前租户
\Spatie\Multitenancy\Http\Middleware\NeedsTenant::class,
// 用 session 的时候需要添加
// \Spatie\Multitenancy\Http\Middleware\EnsureValidTenantSession::class,
],

在路由中使用中间件:

1
2
3
4
5
6
Route::middleware(['tenant', 'auth:sanctum'])->group(function () {
Route::get('/api/xxx', function () {
// 这里已经切换到租户数据库连接
$user = request()->user();
});
});

使用了 tenant 中间件后,自动调用 HeaderTenantFinder::findForRequest() 查找到租户,并切换到租户数据库。

tenant 和 auth:sanctum 放的顺序很重要。
如果用户表放在中心库,那么 auth:sanctum 中间件必须放在 tenant 中间件之前。
如果用户表放在租户库,那么 tenant 中间件必须放在 auth:sanctum 中间件之前,并且还要设置中间件的优先级,把
\Spatie\Multitenancy\Http\Middleware\NeedsTenant::class, 放到 App\Http\Kernel::$middlewarePriority 最前面。

App\Http\Kernel 没看到 $middlewarePriority,则到父类连其列表值一起复制下来。

临时切换切换到租户数据库

$tenant->execute(closure):临时切换、自动恢复、安全隔离。
只在闭包内生效,执行完自动切回原来的租户 / 主库,不会污染全局状态。

1
2
3
4
$tenant = Spatie\Multitenancy\Models\Tenant::find(1);
$tenant->execute(function() {
// 这里可以执行租户数据库的操作
});

永久切换切换到租户数据库

$tenant->makeCurrent():永久切换(直到手动改)、全局生效、需手动恢复。
一旦调用,整个后续请求生命周期都用这个租户库,直到你 forgetCurrent() 或切别的租户。

1
2
3
$tenant = Spatie\Multitenancy\Models\Tenant::find(1);
// 直接把当前租户数据库连接切换到租户库
$tenant->makeCurrent();

最简单的集成方式

上面的集成方式比较优雅,通过子域名或header来集成。

最简单的集成方式是通过URL参数来集成。

  1. 这个方式不需要实现 App\Multitenancy\TenantFinder\TenantFinder 类。
    在 config/multitenancy.php 中配置 tenant_finder 为 ``。

  2. 不需要添加切换租户的路由中间件。

例如:

1
2
3
4
5
Route::get('/api/{tenant}/other', function ($tenant) {
$tenant->execute(function() {
// 这里已经切换到租户数据库连接了
});
});

这个方式的缺点是不优雅,所有路由都需要添加租户参数、所有调用租户数据库的地方都是通过 $tenant->execute(closure) 来切换租户。

Laravel Eloquent 日期系列化

Laravel Eloquent 日期系列化成 json,默认系列化格式为:2023-03-08T08:16:02.000000Z
原因是 Laravel 模型基类的 serializeDate() 时间系列化方法调用 Carbon\Traits\Converter::toJSON() 方法,返回的是 ISO-8601 格式的日期。

引起问题的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
// Illuminate\Database\Eloquent\Concerns\HasAttributes
protected function serializeDate(DateTimeInterface $date)
{
return $date instanceof DateTimeImmutable ?
CarbonImmutable::instance($date)->toJSON() :
Carbon::instance($date)->toJSON();
}

// class Carbon\Traits\Converter
public function toJSON()
{
return $this->toISOString();
}
阅读更多

Laravel Eloquent 模型属性参数详解

@TODO

$table

模型绑定表名,默认是模型类名 kebab-case 带下划线小写的复数形式。

$primaryKey = ‘id’

$keyType = ‘integer’

fillable

attributes

hidden

casts

数字、字符串类型不需要转换。

appends

$timestamps = true

$dateFormat = ‘Y-m-d H:i:s’

Laravel Eloquent 数据库关联模型的增删改操作

Laravel Eloquent ORM 提供了数据模型关联表操作的 API,熟练掌握这些API后,才体会到 Laravel 数据库操作有多高效。

一、hasMany 一对多关联

save/saveMany 创建关联记录

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 新建一条数据 Post 的评论,save 将自动添加 post_id 字段
$comment = new Comment(['message' => 'A new comment.']);
$post = Post::find(1);
$post->comments()->save($comment);

// 保持多条记录
$post->comments()->saveMany([
new Comment(['message' => 'A new comment.']),
new Comment(['message' => 'Another new comment.']),
]);

// 更新后需要重新加载模型及其关联,才会加到 $post->comments 中
$post->refresh();

// 所有评论,包括新保存的评论...
$post->comments;

create/createMany 创建关联记录

save/saveMany 的区别是参数时数组,而不是模型。

阅读更多

Laravel Eloquent 数据库关联查询

一对一 HasOne

tables

1
2
3
4
5
6
- users
- id
- name
- avatars
- user_id
- image

HasOne 关联

1
2
3
4
5
6
7
8
9
// App\Models\User
public function avatar(): HasOne
{
// $this->hasOne('class_name', 'foreign_key', 'local_key');
// foreign_key默认是表名去掉 s 加上 '_id' 后缀
// local_key 默认是 id
return $this->hasOne(Avatar::class);
// 即 $this->hasOne(Avatar::class, 'user_id', 'id');
}

HasOne 反向关联

1
2
3
4
5
6
7
// App\Models\Avatar
public function user(): BelongsTo
{
// $this->belongsTo(User::class, 'foreign_key', 'owner_key');
// return $this->belongsTo(User::class, 'user_id', 'id');
return $this->belongsTo(User::class); // 省略 'foreign_key', 'owner_key' 参数
}

数据操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 新建
$user = User::create(['name', '...']);
Avatar::create([
'user_id' => $user->id,
'image' => '...',
]);

// 更新
$user = User::find(1);
// 更新关联数据
$user->avatar()->update(['image' => '...']);
// 更新模型
$user->update(['name' => '...']);

// 反向关联操作 belongsTo 的 associate/dissociate
$avatar = Avatar::find(1);
$avatar->user()->dissociate(); // 把 user_id 设为 null
$avatar->save(); // 保存更改

$user = User::find(10);
$avatar->user()->associate($user); // 把 user_id 改为 10
$avatar->save();

一对多 HasMany

tables

1
2
3
4
5
6
7
- posts
- id
- title
- comments
- id
- post_id
- comment

模型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// App\Models\Post 关联
public function comments(): HasMany
{
// return $this->hasMany('class_name', 'foreign_key', 'local_key');
return $this->hasMany(Comment::class, 'post_id', 'id');
return $this->hasMany(Comment::class); // 使用默认参数
}

// App\Models\Comment 反向关联
public function post(): BelongsTo
{
// $this->belongsTo(Post::class, 'foreign_key', 'owner_key');
return $this->belongsTo(Post::class, 'post_id', 'id');
return $this->belongsTo(Post::class); // 省略 'foreign_key', 'owner_key' 参数
}

//TODO

子查询

1
2
3
4
5
6
7
User::query()->whereIn(
'id',
UserRolePivot::query()
->select('user_id')
->where('role_id', $roleId)
)->get();
// SELECT * FROM user WHERE id IN( SELECT user_id FROM user_role_pivot WHERE role_id = $roleId)

分组查询

阅读更多