DEV Community

Cover image for Compact Guide: How to sync Google calendar with Laravel
Denis Sinyukov
Denis Sinyukov

Posted on • Edited on • Originally published at coderden.dev

Compact Guide: How to sync Google calendar with Laravel

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"
Enter fullscreen mode Exit fullscreen mode

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

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

Enter fullscreen mode Exit fullscreen mode

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=",
Enter fullscreen mode Exit fullscreen mode

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=",
Enter fullscreen mode Exit fullscreen mode

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', '/')
    );
}
Enter fullscreen mode Exit fullscreen mode

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();
});
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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]
    );
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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));
        }));
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (0)