Be part of JetBrains PHPverse 2026 on June 9 – a free online event bringing PHP devs worldwide together.

nicks's avatar
Level 3

Spark profile photos on Vapor

I am hosting Laravel Spark on AWS Lambda using Vapor. However, the user or team profile photos are not saving up to S3. The instructions in https://docs.vapor.build/1.0/resources/storage.html seem to suggest that when uploading files from the front end, you need to stream them directly to S3 using the Vapor.store method.

Has anyone else tried this, and if so did you need to rewrite the corresponding parts of the Spark code? Or is there some other way to get this to work?

Photos upload fine when tested in a local (traditional server-based) environment

Many thanks for your help.

0 likes
9 replies
Braunson's avatar

I have not used Vapor yet but you can override the upload JS code for Spark to use Vapor.store instead?

You'll want to extend this file and override the proper method..

resources/assets/js/settings/teams/update-team-photo.js

nicks's avatar
Level 3

Thanks Braunson. I'm giving it a go. It looks like the UpdateProfilePhoto@handle and UpdateTeamPhoto@handle functions also need swapping. I'll let you know how it goes and post my solution once I've got it working.

nicks's avatar
nicks
OP
Best Answer
Level 3

The solution turned out to be more complex than expected. Copying it for the user photo here, but the team photo is a similar idea.

  • Update resources/js/spark-components/settings/profile/update-profile-photo.js to use Vapor.store(). Also changed the parameters in the call to POST settings/photo
  • Additional columns photo_bucket, photo_key, photo_content_type in users table
  • New route GET settings/photos to serve the photo from S3 (using a new S3PhotoController)
  • Swap UpdateProfilePhoto@validator to handle the new parameters for POST settings/photo
  • Swap UpdateProfilePhoto@handle to copy the S3 file from tmp/ to profiles/, to delete the old photo and to save details to the user table. It does not resize the photo, as the original code did, to avoid memory issues in AWS Lambda. Recommending to provide guidance to the user in the View to correctly size the photo prior to uploading.

Code copied below

resources/js/spark-components/settings/profile/update-profile-photo.js

// This component overrides settings/profile/update-profile-photo;

Vue.component('spark-update-profile-photo', {
    props: ['user'],

    /**
     * The component's data.
     */
    data() {
        return {
            form: new SparkForm({})
        };
    },


    methods: {
        /**
         * Update the user's profile photo.
         */
        update(e) {
            e.preventDefault();

            if ( ! this.$refs.photo.files.length) {
                return;
            }

            var self = this;

            this.form.startProcessing();

            // Stream the file to S3
            Vapor.store(this.$refs.photo.files[0], {
                progress: progress => {
                    this.uploadProgress = Math.round(progress * 100);
                }
            }).then(response => {
                // Now we send details of the uploaded photo to the server.  
                // We will update the user after this action.
                axios.post('/settings/photo',{
                        bucket:         response.bucket,
                        key:            response.key,
                        content_type:   this.$refs.photo.files[0].type,
                    })
                    .then(
                        () => {
                            Bus.$emit('updateUser');

                            self.form.finishProcessing();
                        },
                        (error) => {
                            self.form.setErrors(error.response.data.errors);
                        }
                    );
            });
        },
    },

    computed: {
        /**
         * Calculate the style attribute for the photo preview.
         */
        previewStyle() {
            return `background-image: url(${this.user.photo_url})`;
        }
    }

});

routes/web.php

…
Route::get('settings/photo', 'S3PhotoController@userPhoto')->name('settings.photo');
…

app/Http/Controllers/S3PhotoController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use App\User;

class S3PhotoController extends Controller
{
       public function __construct()
       {
           $this->middleware('auth');
       }

    // Displays user photo
       public function userPhoto (){
              $user = auth()->user();

        $headers = [
            'Content-Type'          => $user->photo_content_type,
            'X-Vapor-Base64-Encode' => 'True'
        ];

        return Storage::download($user->photo_key,
            $user->id,
            $headers);
       }
}

app/Providers/SparkServiceProvider.php

<?php

namespace App\Providers;

use Laravel\Spark\Spark;
use Laravel\Spark\Providers\AppServiceProvider as ServiceProvider;
use Illuminate\Support\Str;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Facades\Validator;

class SparkServiceProvider extends ServiceProvider
{
    …

    /**
     * Finish configuring Spark for the application.
     *
     * @return void
     */
    public function booted()
    {
        …
        // We need to over-ride Spark's implementation of the update photo handlers
        // in order to be compatible with Vapor
        // One thing the original handler did that this doesn't is to resize the image
        // to fit 300 pixels.  Because of this, the view is modified to recommend the
        // image size to the user prior to upload.

        Spark::swap('UpdateProfilePhoto@validator',function($user, array $data)
        {
            return Validator::make($data, [
                'bucket'        => 'required|string',
                'key'           => 'required|string',
                'content_type'  => 'required|string',
            ]);
        });

        Spark::swap('UpdateProfilePhoto@handle', function($user, array $data)
        {
            $targetKey = str_replace('tmp/', 'profiles/', $data['key']);

            Storage::copy($data['key'],$targetKey);

            $oldPhotoKey = $user->photo_key;

            // We save details of the photo to the user record.  The parameter passed to 
            // the URL is a dummy parameter with no meaning, but is used to force the photo
            // on the settings page to be updated
            $user->forceFill([
                'photo_url'            => route('settings.photo', ['v' => Str::random(4)]),
                'photo_bucket'         => $data['bucket'],
                'photo_key'            => $targetKey,
                'photo_content_type'   => $data['content_type']
            ])->save();

            try{
                Storage::delete($oldPhotoKey);
            } catch (Exception $e) {
            }
        });
}
3 likes
travis.elkins's avatar

@nicks That seems like a lot of work....and deviation from the "default" Spark install. That worries me a bit.

Did you ever try to get direct guidance from the Spark team/support? What did they say, if anything?

Has your solution held over the last couple of months? I'd like to either get rid of support for photos or get it working....I'm just not sure which path requires the most work and maintenance. :-|

(Whatever the case, thanks for sharing your solution. Definitely a big help!)

nicks's avatar
Level 3

@travis.elkins I did confer with Laravel on my solution. There did not appear to be any experience implementing Spark within Vapor, but I did have them review my (original) code and they did not identify any problems.

Looking back, I think I was struggling with swapping methods to validate and update team photos, so instead of swapping methods in SparkServiceProvider.php as suggested above, I ended up creating new routes for updating photos instead, with corresponding changes to S3PhotoController and the front end code. You will notice there is a curious parameter ['v' => Str::random(4)] used when calling these new routes. This was because I was experiencing issues with caching and needed a way round it. If you have a better idea, I would love to hear.

In terms of whether it holds up, we're only in alpha testing right now, so not much experience in the real world, but it has worked well so far.

I reproduce my entire set of changes below, including for both profile and team photos.

database/migrations/2020_03_12_095339_migrate_users_table.php

<?php

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

class MigrateUsersTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('users', function (Blueprint $table) {
            //
            $table->text('photo_bucket')->nullable()->after('photo_url');
            $table->text('photo_key')->nullable()->after('photo_bucket');
            $table->text('photo_content_type')->nullable()->after('photo_key');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('users', function (Blueprint $table) {
            //
            $table->dropColumn(['photo_bucket', 'photo_key', 'photo_content_type']);
        });
    }
}

database/migrations/2020_03_12_193349_migrate_teams_table.php

<?php

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

class MigrateTeamsTable extends Migration
{
    /**
     * Run the migrations.
     *
     * @return void
     */
    public function up()
    {
        Schema::table('teams', function (Blueprint $table) {
            //
            $table->text('photo_bucket')->nullable()->after('photo_url');
            $table->text('photo_key')->nullable()->after('photo_bucket');
            $table->text('photo_content_type')->nullable()->after('photo_key');
        });
    }

    /**
     * Reverse the migrations.
     *
     * @return void
     */
    public function down()
    {
        Schema::table('teams', function (Blueprint $table) {
            //
            $table->dropColumn(['photo_bucket', 'photo_key', 'photo_content_type']);
        });
    }
}

routes/web.php

...
Route::get('settings/photo', 'S3PhotoController@userPhoto')->name('settings.photo');
Route::post('settings/photo', 'S3PhotoController@updateUserPhoto');
Route::get('/settings/'.Spark::teamsPrefix().'/{team}/photo', 'S3PhotoController@teamPhoto')->name('settings.team.photo');
Route::post('/settings/'.Spark::teamsPrefix().'/{team}/photo', 'S3PhotoController@updateTeamPhoto');
...

app/Http/Controllers/S3PhotoController.php

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use App\User;
use App\Team;

// This controller over-rides the user and team photo handling in Spark to make it compatible with
// Laravel Vapor
class S3PhotoController extends Controller
{
	public function __construct()
	{
	    $this->middleware('auth');
        $this->middleware('verified');
	}

    // Displays user photo
	public function userPhoto (){
		return $this->servePhoto(auth()->user());
	}

    // Displays team photo
	public function teamPhoto (Team $team){
        return $this->servePhoto($team);
	}

    // Updates user photo
    public function updateUserPhoto(Request $request){
        return $this->updatePhoto (
            $request,
            auth()->user(),
            route('settings.photo', ['v' => Str::random(4)])
        );
    }

    // Updates team photo
    public function updateTeamPhoto(Request $request, Team $team){
        return $this->updatePhoto (
            $request,
            $team,
            route('settings.team.photo', ['team' => $team->id, 'v' => Str::random(4)])
        );
    }

    // Serves a user or team photo
    private function servePhoto($entity){
        $headers = [
            'Content-Type'          => $entity->photo_content_type,
            'X-Vapor-Base64-Encode' => 'True'
        ];

        return Storage::download($entity->photo_key,
            $entity->id,
            $headers);
    }

    // Updates a user or team photo
    private function updatePhoto(Request $request, $entity, $url){
        $request['size'] = Storage::size($request->key);
        $request['mime_type'] = Storage::mimeType($request->key);

        $request->validate([
            'bucket'        => 'required|string',
            'key'           => 'required|string',
            'size'          => 'max:276480', // Uncompressed 300px square 24 bit image
            'mime_type'     => 'in:image/bmp,image/jpeg,image/png,image/gif'
        ]);

        $targetKey = str_replace('tmp/', 'profiles/', $request->key);

        Storage::copy($request->key,$targetKey);

        $oldPhotoKey = $entity->photo_key;

        // We save details of the photo to the team record.  The parameter passed to 
        // the URL is a dummy parameter with no meaning, but is used to force the photo
        // on the settings page to be updated
        $entity->forceFill([
            'photo_url'            => $url,
            'photo_bucket'         => $request->bucket,
            'photo_key'            => $targetKey,
            'photo_content_type'   => $request->content_type
        ])->save();

        try{
            Storage::delete($oldPhotoKey);
        } catch (Exception $e) {
            // Leave blank to prevent a 500 error occurring.  Optionally you could log the exception.
        }
    }
}

app/Providers/SparkServiceProvider.php

No changes are needed to this file in the final solution

resources/views/vendor/spark/settings/profile/update-profile-photo.blade.php

<spark-update-profile-photo :user="user" inline-template>
    <div class="card card-default" v-if="user">
        <div class="card-header">{{__('Profile Photo')}}</div>

        <div class="card-body">
            <div class="alert alert-danger" v-if="form.errors.has('bucket') || form.errors.has('key') || form.errors.has('size') || form.errors.has('mime_type')">
                @{{ form.errors.get('bucket') }}
                @{{ form.errors.get('key') }}
                @{{ form.errors.get('size') }}
                @{{ form.errors.get('mime_type') }}
            </div>

            <form role="form">
                <div class="form-group row justify-content-center">
                    <div class="col-md-6 d-flex align-items-center">
                        <div class="image-placeholder mr-4">
                            <span role="img" class="profile-photo-preview" :style="previewStyle"></span>
                        </div>
                        <div class="spark-uploader mr-4">
                            <input ref="photo" type="file" class="spark-uploader-control" name="photo" @change="update" :disabled="form.busy">
                            <div class="btn btn-outline-dark">{{__('Update Photo')}}</div>
                        </div>
                    </div>
                    <small>{{__('Photo Guidance')}}</small>
                </div>
            </form>
        </div>
    </div>
</spark-update-profile-photo>

resources/views/vendor/spark/settings/teams/update-team-photo.blade.php

<spark-update-team-photo :user="user" :team="team" inline-template>
    <div class="card card-default" v-if="user">
        <div class="card-header">
            {{__('teams.team_photo')}}
        </div>

        <div class="card-body">
            <div class="alert alert-danger" v-if="form.errors.has('bucket') || form.errors.has('key') || form.errors.has('size') || form.errors.has('mime_type')">
                @{{ form.errors.get('bucket') }}
                @{{ form.errors.get('key') }}
                @{{ form.errors.get('size') }}
                @{{ form.errors.get('mime_type') }}
            </div>

            <form role="form">
                <div class="form-group row justify-content-center">
                    <div class="col-md-6 d-flex align-items-center">
                        <div class="image-placeholder mr-4">
                            <span role="img" class="profile-photo-preview" :style="previewStyle"></span>
                        </div>
                        <div class="spark-uploader mr-4">
                            <input ref="photo" type="file" class="spark-uploader-control" name="photo" @change="update" :disabled="form.busy">
                            <div class="btn btn-outline-dark">{{__('teams.update_logo')}}</div>
                        </div>
                    </div>
                    <small>{{__('teams.logo_guidance')}}</small>
                </div>
            </form>
        </div>
    </div>
</spark-update-team-photo>

resources/js/spark-components/settings/profile/update-profile-photo.js

// This component overrides settings/profile/update-profile-photo;

Vue.component('spark-update-profile-photo', {
    props: ['user'],

    /**
     * The component's data.
     */
    data() {
        return {
            form: new SparkForm({})
        };
    },


    methods: {
        /**
         * Update the user's profile photo.
         */
        update(e) {
            e.preventDefault();

            if ( ! this.$refs.photo.files.length) {
                return;
            }

            var self = this;

            this.form.startProcessing();

            // Stream the file to S3
            Vapor.store(this.$refs.photo.files[0], {
                progress: progress => {
                    this.uploadProgress = Math.round(progress * 100);
                }
            }).then(response => {
                // Now we send details of the uploaded photo to the server.  
                // We will update the user after this action.
                axios.post('/settings/photo',{
                        bucket:         response.bucket,
                        key:            response.key,
                        content_type:   this.$refs.photo.files[0].type,
                    })
                    .then(
                        () => {
                            Bus.$emit('updateUser');

                            self.form.finishProcessing();
                        },
                        (error) => {
                            self.form.setErrors(error.response.data.errors);
                        }
                    );
            });
        },
    },

    computed: {
        /**
         * Calculate the style attribute for the photo preview.
         */
        previewStyle() {
            return `background-image: url(${this.user.photo_url})`;
        }
    }

});

resources/js/spark-components/settings/teams/update-team-photo.js

// This component overrides settings/teams/update-team-photo

Vue.component('spark-update-team-photo', {
    props: ['user', 'team'],

    /**
     * The component's data.
     */
    data() {
        return {
            form: new SparkForm({})
        };
    },


    methods: {
        /**
         * Update the team's photo.
         */
        update(e) {
            e.preventDefault();

            if ( ! this.$refs.photo.files.length) {
                return;
            }

            var self = this;

            this.form.startProcessing();

            // Stream the file to S3
            Vapor.store(this.$refs.photo.files[0], {
                progress: progress => {
                    this.uploadProgress = Math.round(progress * 100);
                }
            }).then(response => {
                // Now we send details of the uploaded photo to the server.  
                // We will update the user after this action.
                axios.post(this.urlForUpdate, {
                        bucket:         response.bucket,
                        key:            response.key,
                        content_type:   this.$refs.photo.files[0].type,                	
                })
                .then(
                    () => {
                        Bus.$emit('updateTeam');
                        Bus.$emit('updateTeams');

                        self.form.finishProcessing();
                    },
                    (error) => {
                        self.form.setErrors(error.response.data.errors);
                    }
                );
            });
        },
    },


    computed: {
        /**
         * Get the URL for updating the team photo.
         */
        urlForUpdate() {
            return `/settings/${Spark.teamsPrefix}/${this.team.id}/photo`;
        },


        /**
         * Calculate the style attribute for the photo preview.
         */
        previewStyle() {
            return `background-image: url(${this.team.photo_url})`;
        }
    }
});

resources/lang/en.json

...
    "Photo Guidance": "Recommended size 300 pixels square. The picture must be in JPG, PNG, BMP or GIF format and no larger than 270KB.",
...
1 like
sandervanhooft's avatar

I can confirm this approach works, thanks @nicks!

If you're using Spark for Mollie on Vapor, check out this package.

It takes care of the required overrides for uploading user/team profile pictures, and invoice pdf downloads. It's inspired by this thread, and this article here.

travis.elkins's avatar

@nicks Thx for your response. Helpful...! :-)

It would be nice to see Spark/Vapor play nice together.

I fear as soon as I'm done doing all this work (which you've already made much easier), then they'll release a new version of Spark that solves this and all its other issues....but will require purchasing a new license, of course. :-|

I'm about ready to make these changes in one project. Fingers crossed...! ;-)

travis.elkins's avatar

@nicks @sandervanhooft LOL! I hadn't gotten around to implementing this yet. (Lower priority for me.) Now, it seems like I saved myself a little time. With Jetstream coming out shortly...as well as a (presumably) modern and up-to-date Spark, it looks like a lot of these problems will just "go away". :-)

1 like

Please or to participate in this conversation.