Support the ongoing development of Laravel.io →

Compact Guide: How to sync Google calendar with Laravel

24 Jan, 2024 7 min read 183 views

Concept of Synchronization

For proper synchronization of your resources is important to understand the principles of Google API. These principles work the same for all Google resources, but will be different for Outlook. We will figure out how and why to use query parameters and study the best practices.

For optimized performance the API uses for important parameters as syncToken and pageToken.

The API data in most cases is returned with pagination, so as not to burden the network and allocate resources on the network and their caching.

Resource pagination - pageToken

When there is more than one page in the response, you can see the nextPageToken field, which stores the data received about the next page.

Do not forget to save the nextPageToken field in case you get an error when synchronizing one of the pages and do not want to retrieve successfully saved resources, but only starting from a certain page.

When you want to get the next page of data, you must specify the pageToken with the value of nextPageToken. You don't need to send additional parameters, because the token already has everything.

An example looks like this:

1. GET /calendars/primary/events

// Response
"items": [...]
"nextPageToken":"CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA"

The following query takes the value from nextPageToken and sends it as a value for pageToken.

2. GET /calendars/primary/events?pageToken=CiAKGjBpNDd2Nmp2Zml2cXRwYjBpOXA

You can control the number of displayed resources in the response through the maxResults parameter.

Synchronization Marker - nextSyncToken

During the first synchronization, an initial query is performed for each resource in the collection that you want to synchronize.

The synchronization token is represented as a field named nextSyncToken in the list operation response.

The nextSyncToken is an important field for optimizing the synchronization of your resources, saving bandwidth. It allows you to retrieve only new data from when the token was first issued.

Don't forget to save this nextPageToken to retrieve resource items from the last received page.

For example, if you create a new event in your calendar, you don't need to retrieve the whole list of events and check and process each one, instead you get only the updated data.

An example looks like this:

1. GET https://www.googleapis.com/calendar/v3/users/me/calendarList

// Response 
...
"items": [...]
"nextSyncToken": "CPDAlvWDx70CEPDAlvWDx70CGAU=",

The nextSyncToken field will be only in the response on the last page, because all requests are given on a page-by-page basis and will contain the nextPageToken parameter.

The following query takes the value from nextSyncToken and sends it as a value for syncToken.

2. GET https://www.googleapis.com/calendar/v3/users/me/calendarList?syncToken=CPDAlvWDx70CEPDAlvWDx70CGAU=

// Response 
...
"items": [...]
"nextSyncToken": "v7GC9pHgvO6kpTHAxRx71KebukwS=",

In cases where your syncToken is no longer valid, you should remove it from the database and re-request the entire resource collection.

Synchronizing Google calendars

In the previous post, we set up authorization via oauth2, after which the Google Account data is written to the database.

After successful authorization, you should get the list of available calendars of the user from the account.

public function callback(string $driver): RedirectResponse
{
    /** @var ProviderInterface $provider */
    $provider = $this->manager->driver($driver);

    /** @var Account $account */
    $account = $provider->callback();

    $accountId = app(AccountService::class)->createFrom($account, $driver);

    $account->setId($accountId);

    // Sync calendars of user account
    $provider->synchronize('Calendar', $account);

    return redirect()->to(
      config('services.' . $driver . '.redirect_callback', '/')
    );
}

For calendars records and their basic information let's design a table in the database, which looks like:

Schema::create('calendars', function (Blueprint $table) {
    $table->id();
    $table->string('summary')->nullable();
    $table->string('timezone')->nullable();
    $table->string('provider_id');
    $table->string('provider_type');
    $table->text('description')->nullable();
    $table->text('page_token')->nullable();
    $table->text('sync_token')->nullable();
    $table->timestamp('last_sync_at')->nullable();
    $table->boolean('selected')->default(false);
    $table->unsignedBigInteger('account_id');
    $table->foreign('account_id')->references('id')->on('calendar_accounts')->onDelete('CASCADE');
    $table->index(['provider_id', 'provider_type']);
    $table->timestamps();
});

The table will store information about the calendar, tokens for synchronization and pagination for events, as well as links to his account.

To perform synchronization, add some logic to our calendar driver - GoogleProvider.php.

public function synchronize(string $resource, Account $account, array $options = [])
{
    $resource = Str::ucfirst($resource);

    $method = 'synchronize' . Str::plural($resource);

    $synchronizer = $this->getSynchronizer();

    if (method_exists($synchronizer, $method) === false) {
        throw new \InvalidArgumentException('Method is not allowed.', 400);
    }

    return call_user_func([$synchronizer, $method], $account, $options);
}

The getSynchronizer() function will return us the synchronizer class, which will mediate the resources. Which has method: synchronizeCalendars().

public function synchronizeCalendars(Account $account, array $options = [])
{
    $token = $account->getToken();
    $accountId = $account->getId();
    $syncToken = $account->getSyncToken();
    
    if ($token->isExpired()) {
        return false;
    }

    $query = array_merge([
        'maxResults' => 100,
        'minAccessRole' => 'owner',
    ], $options['query'] ?? []);

    if (isset($syncToken)) {
        $query = [
            'syncToken' => $syncToken,
        ];
    }

    $body = $this->call('GET', "/calendar/{$this->provider->getVersion()}/users/me/calendarList", [
        'headers' => ['Authorization' => 'Bearer ' . $token->getAccessToken()],
        'query' => $query
    ]);

    $nextSyncToken = $body['nextSyncToken'];
    $calendarIterator = new \ArrayIterator($body['items']);

    /** @var CalendarRepository $calendarRepository */
    $calendarRepository = app(CalendarRepository::class);

    // Check user calendars
    $providersIds = $calendarRepository
        ->setColumns(['provider_id'])
        ->getByAttributes(['account_id' => $accountId, 'provider_type' => $this->provider->getProviderName()])
        ->pluck('provider_id');

    $now = now();

    while ($calendarIterator->valid()) {
        $calendar = $calendarIterator->current();
        $calendarId = $calendar['id'];

        // Delete account calendar by ID
        if (key_exists('deleted', $calendar) && $calendar['deleted'] === true && $providersIds->contains($calendarId)) {
            $calendarRepository->deleteWhere([
                'provider_id' => $calendarId,
                'provider_type' => $this->provider->getProviderName(),
                'account_id' => $accountId,
            ]);

        // Update account calendar by ID
        } else if ($providersIds->contains($calendarId)) {
            $calendarRepository->updateByAttributes(
                [
                    'provider_id' => $calendarId,
                    'provider_type' => $this->provider->getProviderName(),
                    'account_id' => $accountId,
                ],
                [
                    'summary' => $calendar['summary'],
                    'timezone' => $calendar['timeZone'],
                    'description' => $calendar['description'] ?? null,
                    'updated_at' => $now,
                ]
            );
        // Create account calendar
        } else {
            $calendarRepository->insert([
                'provider_id' => $calendarId,
                'provider_type' => $this->provider->getProviderName(),
                'account_id' => $accountId,
                'summary' => $calendar['summary'],
                'timezone' => $calendar['timeZone'],
                'description' => $calendar['description'] ?? null,
                'selected' => $calendar['selected'] ?? false,
                'created_at' => $now,
                'updated_at' => $now,
            ]);
        }

        $calendarIterator->next();
    }

    $this->getAccountRepository()->updateByAttributes(
        ['id' => $accountId],
        ['sync_token' => Crypt::encryptString($nextSyncToken), 'updated_at' => $now]
    );
}

The code above fetch list of user calendars where it has owner access. After each calendar is checked for consistency in the database and actions are taken to delete, update or create.

As a result, we remember the synchronization token for the current account in the database.

Code Notes

  • Refresh token will be implemented in the following articles.
  • Google API return 100 calendars by default. Let's omit the point that we include more.
  • We get calendars where the user can read and modify events and access control lists.
  • The presence of syncToken in the request does not allow other parameters. Throws exception.
  • nextSyncToken is encoded before writing to the database.

Performance tips

To get a gzip-encoded response, you need to do:

  • Set the Accept-Encoding header
  • Change your user agent so that it contains the gzip string.

Each request to api google will contain these headers.

protected function headers(array $headers = []): array
{
    return array_merge([
        'Content-Type' => 'application/json',
        'Accept-Encoding' => 'gzip',
        'User-Agent' => config('app.name') . ' (gzip)',

    ], $headers);
}

Sync calendar command

To test the synchronization result, we will use the commands in Laravel. let's call it synchronize:calendars.

Commands will retrieve all accounts from the database and still synchronize the list of calendars.

public function handle()
{
    /** @var GoogleProvider $provider */
    $provider = app(CalendarManager::class)->driver('google');

    $accounts = app(AccountRepository::class)->get();

    foreach ($accounts as $accountModel) {
        $provider->synchronize('Calendar', tap(new Account(), function ($account) use ($accountModel) {

            $token = Crypt::decrypt($accountModel->token);
            $syncToken = '';

            if (isset($accountModel->sync_token)) {
                $syncToken = Crypt::decryptString($accountModel->sync_token);
            }

            $account
                ->setId($accountModel->id)
                ->setProviderId($accountModel->provider_id)
                ->setUserId($accountModel->user_id)
                ->setName($accountModel->name)
                ->setEmail($accountModel->email)
                ->setPicture($accountModel->picture)
                ->setSyncToken($syncToken)
                ->setToken(TokenFactory::create($token));
        }));
    }
}

Bottom line

In this article, we looked at how to sync google calendars to your Laravel app. We looked at the basic parameters for getting api resources. Created code and optimized it.

Some aspects of the implementation were omitted, but the entire list of changes can be seen at the link.

In the next article we will see how to synchronize google events.

Related links

Last updated 2 months ago.

driesvints liked this article

1
Like this article? Let the author know and give them a clap!
dnsinyukov (Denis Sinyukov) Web Developerr | FullStack

Other articles you might like

April 11th 2024

Laravel Facades - Write Testable Code

Hello 👋 For one reason or another, Laravel Facades don't get much love. I often read about how the...

Read article
March 11th 2024

How to get your Laravel app from 0 to 9 with Larastan

Finding bugs in your Laravel app before it's even executed is possible, thanks to Larastan, which is...

Read article
February 4th 2022

How to find the slowest query in your application

Is your website slow? does it take ages to load? Are the users complaining that it's almost unusable...

Read article

We'd like to thank these amazing companies for supporting us

Your logo here?

Laravel.io

The Laravel portal for problem solving, knowledge sharing and community building.

© 2024 Laravel.io - All rights reserved.